diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index f1dd5dd8aa..637b89c162 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -36,9 +36,6 @@ const SEARCH_DEBOUNCE_MS = 250 const SEARCH_QUERY_MAX_CODE_UNITS = 500 /** Session rows visible per Workspace before the local overflow control. */ const COLLAPSED_SESSION_LIMIT = 5 -const EMPTY_WORKSPACE_EXPANSION: Readonly> = Object.freeze({}) -const EMPTY_RECENT_SESSION_ORDER: Readonly> = Object.freeze({}) -const EMPTY_RECENT_SESSION_UPDATED_AT: Readonly>>> = Object.freeze({}) /** Keep controlled input and RPC payload inside the session.search wire contract. */ function sanitizeSearchQuery(value: string): string { @@ -245,7 +242,11 @@ function SessionTree({ nextOrder.sort((a, b) => compareSessionRecency(a, b, list.byId)) } else { const promoted = sessionIds - .filter((id) => previousUpdatedAt[id] === undefined || list.byId[id]!.updatedAt > previousUpdatedAt[id]!) + .filter((id) => { + const session = list.byId[id] + return session !== undefined + && (previousUpdatedAt[id] === undefined || session.updatedAt > previousUpdatedAt[id]) + }) .sort((a, b) => compareSessionRecency(a, b, list.byId)) if (promoted.length > 0) { const promotedIds = new Set(promoted) @@ -253,7 +254,10 @@ function SessionTree({ } } const nextUpdatedAt: Record = {} - for (const id of sessionIds) nextUpdatedAt[id] = list.byId[id]!.updatedAt + for (const id of sessionIds) { + const session = list.byId[id] + if (session !== undefined) nextUpdatedAt[id] = session.updatedAt + } const orderChanged = previousOrder === undefined || nextOrder.length !== previousOrder.length || nextOrder.some((id, index) => id !== previousOrder[index]) @@ -299,7 +303,7 @@ function SessionTree({ const nextOrder = account.sessionIds.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 as string, nextOrder.map(id => id as string)) + setRecentSessionOrder(activeDrag.workspaceId, nextOrder.map(id => id as string)) return } insertSessionBefore(activeDrag.workspaceId, activeDrag.sessionId, anchor).catch((reason: unknown) => { @@ -350,112 +354,112 @@ function SessionTree({ // Group section: header row + expanded top-level session rows. The // inter-group breathing room is the section's own margin // (WorkspaceBrowser.module.css). -
{ - e.preventDefault() - e.dataTransfer.dropEffect = 'move' - hoverWorkspace(workspaceGroupHalf(e)) - }} - onDrop={workspaceDrag === null || dropWorkspace === undefined - ? undefined - : (e) => { - e.preventDefault() - dropWorkspace(workspaceGroupHalf(e)) - }} - > - { - if (group.expanded) { - setExpandedSessionGroups(keys => keys.filter(key => key !== group.key)) - } - setWorkspaceExpanded(group.key, !group.expanded) - }} - onCreate={() => { - if (group.workspaceId !== undefined) startSession(group.workspaceId) - }} - drag={workspaceDragProps} - actions={group.workspaceId === undefined +
{ - /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */ - if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) - }, - delete: () => { - /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */ - if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label) - }, + : (e) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + hoverWorkspace(workspaceGroupHalf(e)) }} - /> - {(expandedSessionGroups.includes(group.key) - ? group.sessions - : group.sessions.slice(0, COLLAPSED_SESSION_LIMIT) - ).map((node) => { + onDrop={workspaceDrag === null || dropWorkspace === undefined + ? undefined + : (e) => { + e.preventDefault() + dropWorkspace(workspaceGroupHalf(e)) + }} + > + { + if (group.expanded) { + setExpandedSessionGroups(keys => keys.filter(key => key !== group.key)) + } + setWorkspaceExpanded(group.key, !group.expanded) + }} + onCreate={() => { + if (group.workspaceId !== undefined) startSession(group.workspaceId) + }} + drag={workspaceDragProps} + actions={group.workspaceId === undefined + ? undefined + : { + rename: () => { + /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */ + if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) + }, + delete: () => { + /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */ + if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label) + }, + }} + /> + {(expandedSessionGroups.includes(group.key) + ? 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 : { - start: () => { - sessionDropCommitted.current = false - setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null }) - }, - active: sameGroupDrag, - marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null, - hover: (half: 'before' | 'after') => { + const draggable = group.workspaceId !== undefined + const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId + const dragProps = !draggable || group.workspaceId === undefined ? undefined : { + start: () => { + sessionDropCommitted.current = false + setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null }) + }, + active: sameGroupDrag, + marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null, + hover: (half: 'before' | 'after') => { /* v8 ignore next -- narrowing guard: Rows gates hover on `active`, which is false while the drag state is null. */ - setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } })) - }, - drop: (half: 'before' | 'after') => { + setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } })) + }, + drop: (half: 'before' | 'after') => { /* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */ - if (drag === null) return - commitSessionDrag(drag, { id: node.id, half }) - }, - end: () => { - if (drag?.over !== null && drag?.over !== undefined) commitSessionDrag(drag, drag.over) - else setDrag(null) - sessionDropCommitted.current = false - }, - } - return ( - - ) - })} - {group.sessions.length > COLLAPSED_SESSION_LIMIT && ( - - )} -
+ if (drag === null) return + commitSessionDrag(drag, { id: node.id, half }) + }, + end: () => { + if (drag?.over !== null && drag?.over !== undefined) commitSessionDrag(drag, drag.over) + else setDrag(null) + sessionDropCommitted.current = false + }, + } + return ( + + ) + })} + {group.sessions.length > COLLAPSED_SESSION_LIMIT && ( + + )} +
) })} @@ -465,13 +469,13 @@ function SessionTree({ } /** The flat "In one list" body: every session a top-level row, newest-first. */ -function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, orderBy, t }: Pick< - SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 'orderBy' | 't' +function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick< + SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't' >) { const list = useSessions(s => s) const rows = useMemo( - () => deriveFlat(list, archivedSessionIds, orderBy), - [list, archivedSessionIds, orderBy], + () => deriveFlat(list, archivedSessionIds), + [list, archivedSessionIds], ) const now = Date.now() return ( @@ -604,16 +608,13 @@ export function WorkspaceBrowser({ // flow reads): a composition without a picking affordance can add nothing. const directoryFlowAvailable = useDirectoryFlow(occupied => occupied) const groupBy = useStore(s => s.groupBy) - // A live HMR handoff can retain the pre-ordering store instance until the - // slot is remounted; manual is the established Workspace order. - const orderBy = useStore(s => s.orderBy ?? 'manual') + const orderBy = useStore(s => s.orderBy) // A flat list has no single Workspace account to drag. Keep the stored // grouped preference intact while presenting the flat list by recency. const effectiveOrderBy = groupBy === 'flat' && orderBy === 'manual' ? 'updated' : orderBy - // HMR can retain the preceding view-store instance until the slot remounts. - const workspaceExpansion = useStore(s => s.workspaceExpansion ?? EMPTY_WORKSPACE_EXPANSION) - const recentSessionOrder = useStore(s => s.recentSessionOrder ?? EMPTY_RECENT_SESSION_ORDER) - const recentSessionUpdatedAt = useStore(s => s.recentSessionUpdatedAt ?? EMPTY_RECENT_SESSION_UPDATED_AT) + const workspaceExpansion = useStore(s => s.workspaceExpansion) + const recentSessionOrder = useStore(s => s.recentSessionOrder) + const recentSessionUpdatedAt = useStore(s => s.recentSessionUpdatedAt) // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. const [query, setQuery] = useState('') @@ -958,7 +959,7 @@ export function WorkspaceBrowser({ ) : ( diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 553742ef90..292b574b2f 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -208,8 +208,8 @@ function sessionNode( /** * Derive the workspace browser groups with every session as a top-level row. * - * Every group shows; sessions populate under expanded groups, preserving - * Host account order. Blank sessions are excluded except for the selected + * Every group shows; sessions populate under expanded groups in the selected + * local order. Blank sessions are excluded except for the selected * provisional New Session row; archived sessions are excluded everywhere. * Content search lives outside this derivation * (see {@link deriveSearchResults}). @@ -217,6 +217,7 @@ function sessionNode( * @param workspaces - real workspaces in stable Host order. * @param archivedSessionIds - registry-global archive set. * @param view - local expansion arrays. + * @param orderBy - local session ordering mode. * @returns group sections in render order. */ export function deriveGroups( @@ -263,7 +264,6 @@ export function deriveGroups( export function deriveFlat( list: SessionListState, archivedSessionIds: readonly SessionId[], - orderBy: SessionOrderBy = 'updated', ): SessionNode[] { const archived = new Set(archivedSessionIds) const descendants = indexSubagentDescendants(list.byId) @@ -273,7 +273,7 @@ export function deriveFlat( if (s === undefined || !sessionVisible(s, list.current, archived)) continue rows.push(s) } - sortSessions(rows, orderBy === 'manual' ? 'updated' : orderBy) + sortSessions(rows) return rows.map(session => sessionNode(session, descendants)) }