feat(ui-workspace): reorder ungrouped sessions

This commit is contained in:
_Kerman
2026-08-11 19:14:57 +08:00
parent 94897de821
commit 5bb70d7cf2
10 changed files with 156 additions and 43 deletions

View File

@@ -130,7 +130,8 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
/** In-flight root-row drag: source identity plus the current insert marker. */
interface DragState {
workspaceId: WorkspaceId
/** Workspace id, or {@link UNGROUPED_KEY} for the browser-local loose-session account. */
workspaceKey: string
sessionId: SessionNode['id']
/** Row the marker sits on and which half (insert above/below it). */
over: { id: SessionNode['id']; half: 'before' | 'after' } | null
@@ -227,13 +228,22 @@ function SessionTree({
() => Object.entries(workspaceExpansion).filter(([, expanded]) => expanded).map(([key]) => key),
[workspaceExpansion],
)
const ungroupedSessionIds = useMemo(() => {
const accounted = new Set(workspaces.flatMap(workspace => workspace.sessionIds))
return list.ids.filter(id => list.byId[id] !== undefined && !accounted.has(id))
}, [list, workspaces])
useEffect(() => {
if (list.phase !== 'ready') return
const switchedToUpdated = previousOrderBy.current !== 'updated' && orderBy === 'updated'
previousOrderBy.current = orderBy
for (const workspace of workspaces) {
const key = workspace.workspaceId as string
const sessionIds = workspace.sessionIds.filter(id => list.byId[id] !== undefined)
const accounts = [
...workspaces.map(workspace => ({
key: workspace.workspaceId as string,
sessionIds: workspace.sessionIds.filter(id => list.byId[id] !== undefined),
})),
{ key: UNGROUPED_KEY, sessionIds: ungroupedSessionIds },
]
for (const { key, sessionIds } of accounts) {
const previousOrder = recentSessionOrder[key]
const previousUpdatedAt = recentSessionUpdatedAt[key] ?? {}
let nextOrder = reconciledSessionOrder(sessionIds, previousOrder)
@@ -266,7 +276,7 @@ function SessionTree({
syncRecentSessions(key, nextOrder.map(id => id as string), nextUpdatedAt)
}
}
}, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, workspaces])
}, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, ungroupedSessionIds, workspaces])
const orderedWorkspaces = useMemo(() => {
return workspaces.map((workspace) => {
const stored = recentSessionOrder[workspace.workspaceId as string]
@@ -274,16 +284,25 @@ function SessionTree({
return { ...workspace, sessionIds }
})
}, [recentSessionOrder, workspaces])
const orderedUngroupedSessionIds = useMemo(
() => reconciledSessionOrder(ungroupedSessionIds, recentSessionOrder[UNGROUPED_KEY]),
[recentSessionOrder, ungroupedSessionIds],
)
const groups = useMemo(
() => deriveGroups(list, orderedWorkspaces, archivedSessionIds, { expandedProjects }),
[list, orderedWorkspaces, archivedSessionIds, expandedProjects],
() => deriveGroups(list, orderedWorkspaces, archivedSessionIds, {
expandedProjects,
...(recentSessionOrder[UNGROUPED_KEY] === undefined
? {}
: { ungroupedOrder: recentSessionOrder[UNGROUPED_KEY] }),
}),
[list, orderedWorkspaces, archivedSessionIds, expandedProjects, recentSessionOrder],
)
const now = Date.now()
const commitSessionDrag = (activeDrag: DragState, over: NonNullable<DragState['over']>): void => {
if (sessionDropCommitted.current) return
sessionDropCommitted.current = true
setDrag(null)
const group = groups.find(candidate => candidate.workspaceId === activeDrag.workspaceId)
const group = groups.find(candidate => candidate.key === activeDrag.workspaceKey)
if (group === undefined) return
const targetIndex = group.sessions.findIndex(session => session.id === over.id)
if (targetIndex === -1) return
@@ -294,14 +313,16 @@ function SessionTree({
? group.sessions.length
: group.sessions.findIndex(session => session.id === anchor)
if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
const account = orderedWorkspaces.find(workspace => workspace.workspaceId === activeDrag.workspaceId)
if (account === undefined) return
const nextOrder = account.sessionIds.filter(id => id !== activeDrag.sessionId)
const accountSessionIds = activeDrag.workspaceKey === UNGROUPED_KEY
? orderedUngroupedSessionIds
: orderedWorkspaces.find(workspace => workspace.workspaceId === activeDrag.workspaceKey)?.sessionIds
if (accountSessionIds === undefined) return
const nextOrder = accountSessionIds.filter(id => id !== activeDrag.sessionId)
const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor)
nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId)
setRecentSessionOrder(activeDrag.workspaceId, nextOrder.map(id => id as string))
if (orderBy === 'updated') return
insertSessionBefore(activeDrag.workspaceId, activeDrag.sessionId, anchor).catch((reason: unknown) => {
setRecentSessionOrder(activeDrag.workspaceKey, nextOrder.map(id => id as string))
if (orderBy === 'updated' || activeDrag.workspaceKey === UNGROUPED_KEY) return
insertSessionBefore(activeDrag.workspaceKey as WorkspaceId, activeDrag.sessionId, anchor).catch((reason: unknown) => {
console.warn('session reorder rejected:', reason)
})
}
@@ -427,15 +448,13 @@ function SessionTree({
? group.sessions
: group.sessions.slice(0, COLLAPSED_SESSION_LIMIT)
).map((node) => {
// Draggable: real-workspace session rows. The drag
// never leaves its group — rows of other groups show no markers
// and reject drops (visual movement confined to this section).
const draggable = group.workspaceId !== undefined
const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId
const dragProps = !draggable || group.workspaceId === undefined ? undefined : {
// Session drag never leaves its group. Ungrouped writes only the
// browser-local account; real Workspaces may also write Host order.
const sameGroupDrag = drag !== null && drag.workspaceKey === group.key
const dragProps = {
start: () => {
sessionDropCommitted.current = false
setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null })
setDrag({ workspaceKey: group.key, sessionId: node.id, over: null })
},
active: sameGroupDrag,
marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null,

View File

@@ -78,6 +78,8 @@ export interface SearchResultSet {
/** Viewing state consumed by the derivation. */
export interface TreeView {
expandedProjects: readonly string[]
/** Browser-local order for Sessions without a backing Workspace account. */
ungroupedOrder?: readonly string[]
}
interface Group {
@@ -139,21 +141,41 @@ function buildGroup(
order: 'account' | 'recency',
): Group {
const sessions = [...members]
// Workspace order is the caller-selected sessionIds; only Ungrouped lacks
// an account order and therefore falls back to recency.
// Real Workspace order comes from sessionIds. Ungrouped falls back to
// recency until the browser supplies its persisted local order.
if (order === 'recency') sessions.sort(byRecency)
return { key, workspaceId, cwd, createdAt, label, sessions }
}
/** Apply a stored Ungrouped order and append newly loose Sessions by recency. */
function orderedUngrouped(members: readonly SessionSummary[], stored: readonly string[]): SessionSummary[] {
const byId = new Map(members.map(session => [session.id as string, session]))
const included = new Set<string>()
const ordered: SessionSummary[] = []
for (const key of stored) {
const session = byId.get(key)
if (session === undefined || included.has(key)) continue
ordered.push(session)
included.add(key)
}
for (const session of [...members].sort(byRecency)) {
if (included.has(session.id)) continue
ordered.push(session)
}
return ordered
}
/**
* Group Sessions by Host Workspace: one group per entity in stable Host
* order, with members resolved from sessionIds in their stored order. Sessions
* outside every Workspace trail in the recency-ordered Ungrouped bucket.
* outside every Workspace trail in the browser-local Ungrouped order, which
* falls back to recency before that order is initialized.
*/
function groupByWorkspace(
list: SessionListState,
workspaces: readonly WorkspaceView[],
archived: ReadonlySet<SessionId>,
ungroupedOrder: readonly string[] | undefined,
): Group[] {
const groups: Group[] = []
const accounted = new Set<SessionId>()
@@ -176,7 +198,15 @@ function groupByWorkspace(
.filter((s): s is SessionSummary =>
s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived))
if (stray.length > 0) {
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
groups.push(buildGroup(
UNGROUPED_KEY,
undefined,
undefined,
undefined,
UNGROUPED_LABEL,
ungroupedOrder === undefined ? stray : orderedUngrouped(stray, ungroupedOrder),
ungroupedOrder === undefined ? 'recency' : 'account',
))
}
return groups
}
@@ -225,7 +255,7 @@ export function deriveGroups(
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
?? UNGROUPED_KEY
const groups: GroupNode[] = []
for (const g of groupByWorkspace(list, workspaces, archived)) {
for (const g of groupByWorkspace(list, workspaces, archived, view.ungroupedOrder)) {
const expanded = expandedProjects.has(g.key)
groups.push({
key: g.key,