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