diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 0c16d61cbb..0a1fb1cae3 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -371,16 +371,15 @@ const SessionsSection: React.FC = ({ void orderingRevision; return new Set([...flowChatState.sessions.keys()].filter(sessionNavStatusService.isRunning)); }, [flowChatState.sessions, orderingRevision]); - const catalogQueryJson = useMemo(() => { - void orderingRevision; - return JSON.stringify(workspaceSessionCatalogQuery(workspaceId, sessionOrdering, sessionShow, - sessionFilters, sourceAgent, flowChatState.sessions.values(), sessionNavStatusService.getSortTimestamp) ?? null); - }, [workspaceId, sessionOrdering, sessionShow, sessionFilters, sourceAgent, flowChatState.sessions, orderingRevision]); + const catalogQueryJson = useMemo(() => JSON.stringify(workspaceSessionCatalogQuery( + workspaceId, sessionOrdering, sessionShow, sessionFilters, sourceAgent, + ) ?? null), [workspaceId, sessionOrdering, sessionShow, sessionFilters, sourceAgent]); const captureCatalogQuery = useCallback((query: SessionCatalogQuery) => captureWorkspaceSessionCatalogQuery( query, flowChatStore.getState().sessions.values(), sessionNavStatusService.getSortTimestamp, ), []); - // Capture the view's observed order only when beginning a catalog. Hydration - // and streaming must not restart it; a refresh or device switch must rebind it. + // Capture the view's observed order only when beginning a catalog. Hydration, + // streaming and run starts must not restart it; a refresh or device switch + // must rebind it. const catalogQuery = useMemo(() => { void catalogRefreshVersion; void initialMetadataKey; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/workspaceSessionCatalogQuery.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/workspaceSessionCatalogQuery.test.ts index b7caf17e9b..d6cfeaa25b 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/workspaceSessionCatalogQuery.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/workspaceSessionCatalogQuery.test.ts @@ -12,7 +12,7 @@ describe('workspace catalog query selection', () => { it('pins an already-observed timestamp even while it still matches local metadata', () => { const observed = session('observed', { lastFinishedAt: 10 }); const viewQuery = workspaceSessionCatalogQuery('main', 'updated', 'all', - DEFAULT_WORKSPACE_SESSION_FILTERS, 'all', [observed], recency); + DEFAULT_WORKSPACE_SESSION_FILTERS, 'all'); const query = captureWorkspaceSessionCatalogQuery(viewQuery!, [observed], recency); // The host can have finished another turn since this local record loaded. // Its new timestamp must not choose a different page than the frozen view. @@ -20,9 +20,6 @@ describe('workspace catalog query selection', () => { expect(viewQuery?.sortTimestamps).toBeUndefined(); const hydrated = session('newly-loaded', { lastFinishedAt: 9 }); - const afterHydration = workspaceSessionCatalogQuery('main', 'updated', 'all', - DEFAULT_WORKSPACE_SESSION_FILTERS, 'all', [observed, hydrated], recency); - expect(sessionCatalogQueryKey(afterHydration)).toBe(sessionCatalogQueryKey(viewQuery)); expect(captureWorkspaceSessionCatalogQuery(query, [observed, hydrated], recency).sortTimestamps) .toEqual({ observed: 10, 'newly-loaded': 9 }); }); @@ -47,16 +44,16 @@ describe('workspace catalog query selection', () => { it('uses bounded paging for the default view and every source selector', () => { for (const source of ['all', 'openbitfun', 'codex', 'zcode']) { expect(workspaceSessionCatalogQuery('main', DEFAULT_WORKSPACE_SESSION_VIEW.ordering, 'all', - DEFAULT_WORKSPACE_SESSION_FILTERS, source, [], recency)).toEqual({ workspaceId: 'main', ordering: 'updated', source }); + DEFAULT_WORKSPACE_SESSION_FILTERS, source)).toEqual({ workspaceId: 'main', ordering: 'updated', source }); } }); it('retains the complete legacy projection for name, status, unread, attention and every richer filter', () => { for (const ordering of ['name', 'status'] as const) { - expect(workspaceSessionCatalogQuery('main', ordering, 'all', DEFAULT_WORKSPACE_SESSION_FILTERS, 'all', [], recency)).toBeUndefined(); + expect(workspaceSessionCatalogQuery('main', ordering, 'all', DEFAULT_WORKSPACE_SESSION_FILTERS, 'all')).toBeUndefined(); } for (const show of ['unread', 'attention'] as const) { - expect(workspaceSessionCatalogQuery('main', 'updated', show, DEFAULT_WORKSPACE_SESSION_FILTERS, 'codex', [], recency)).toBeUndefined(); + expect(workspaceSessionCatalogQuery('main', 'updated', show, DEFAULT_WORKSPACE_SESSION_FILTERS, 'codex')).toBeUndefined(); } const filters = [ { hiddenStatuses: ['running'] }, { hiddenEnvironments: ['remote'] }, @@ -65,21 +62,31 @@ describe('workspace catalog query selection', () => { for (const filter of filters) { expect(workspaceSessionCatalogQuery('main', 'updated', 'all', { ...DEFAULT_WORKSPACE_SESSION_FILTERS, ...filter } as typeof DEFAULT_WORKSPACE_SESSION_FILTERS, - 'all', [], recency)).toBeUndefined(); + 'all')).toBeUndefined(); } }); - it('preserves observed run ordering only within the owning workspace, including isolated worktrees', () => { + it('does not restart the view when a session starts running', () => { + // The running row moves ahead of rows whose relative order is unchanged, + // so the host pages already read for this view remain valid. + expect(sessionCatalogQueryKey(workspaceSessionCatalogQuery('main', 'updated', 'all', + DEFAULT_WORKSPACE_SESSION_FILTERS, 'all'))).toBe(sessionCatalogQueryKey({ + workspaceId: 'main', ordering: 'updated', source: 'all', + })); + }); + + it('pins run ordering only within the owning workspace, including isolated worktrees', () => { const sessions = [session('historical'), session('running'), session('peer', { workspaceId: 'other' }), session('isolated', { workspaceId: 'worktree', projectWorkspaceId: 'main', config: { executionTarget: { kind: 'managedWorktree', rootPath: '/project/.worktrees/one', } } }), session('linked', { workspaceId: 'worktree', projectWorkspaceId: 'main' })]; const getTimestamp = (value: Session) => value.sessionId === 'historical' ? recency(value) : 100; - const query = workspaceSessionCatalogQuery('main', 'updated', 'all', DEFAULT_WORKSPACE_SESSION_FILTERS, 'all', sessions, getTimestamp); - expect(query?.sortTimestamps).toEqual({ running: 100, isolated: 100 }); + const query = workspaceSessionCatalogQuery('main', 'updated', 'all', DEFAULT_WORKSPACE_SESSION_FILTERS, 'all'); expect(captureWorkspaceSessionCatalogQuery(query!, sessions, getTimestamp).sortTimestamps) .toEqual({ historical: 20, running: 100, isolated: 100 }); - expect(workspaceSessionCatalogQuery('main', 'created', 'all', DEFAULT_WORKSPACE_SESSION_FILTERS, 'all', sessions, getTimestamp)?.sortTimestamps).toBeUndefined(); - expect(sessionCatalogQueryKey(query)).toBe(sessionCatalogQueryKey({ ...query!, sortTimestamps: { isolated: 100, running: 100 } })); + const created = workspaceSessionCatalogQuery('main', 'created', 'all', DEFAULT_WORKSPACE_SESSION_FILTERS, 'all'); + expect(captureWorkspaceSessionCatalogQuery(created!, sessions, getTimestamp).sortTimestamps).toBeUndefined(); + expect(sessionCatalogQueryKey({ ...query!, sortTimestamps: { running: 100, isolated: 100 } })) + .toBe(sessionCatalogQueryKey({ ...query!, sortTimestamps: { isolated: 100, running: 100 } })); }); }); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/workspaceSessionCatalogQuery.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/workspaceSessionCatalogQuery.ts index b3d6546923..672fead048 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/workspaceSessionCatalogQuery.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/workspaceSessionCatalogQuery.ts @@ -23,32 +23,19 @@ export function workspaceSessionCatalogTotal(visibleCount: number, catalog: { pa ? (catalog.total ?? 0) + visibleCount - (catalog.knownCount ?? visibleCount) : 0); } -/** Select supported views and the ordering changes that restart their reader. - * Newly hydrated rows alone must not invalidate an in-progress catalog. */ +/** Select supported views. Only a view change restarts their reader: a session + * that starts running moves ahead of rows whose relative order is unchanged, + * so the local projection reorders it without discarding the host pages. */ export function workspaceSessionCatalogQuery( workspaceId: string | undefined, ordering: WorkspaceSessionOrdering, show: WorkspaceSessionShow, filters: WorkspaceSessionFilters, source: string, - sessions: Iterable, - getSortTimestamp: (session: Session) => number, ): SessionCatalogQuery | undefined { if (!workspaceId || (ordering !== 'updated' && ordering !== 'created') || show !== 'all' || hasWorkspaceSessionFilters(filters)) return; - const sortTimestamps: Record = {}; - if (ordering === 'updated') { - for (const session of sessions) { - if (!sessionBelongsToWorkspaceNavRow(session, workspaceId)) continue; - const timestamp = getSortTimestamp(session); - if (timestamp !== (session.lastFinishedAt ?? session.createdAt)) { - sortTimestamps[session.sessionId] = timestamp; - } - } - } - return { workspaceId, ordering, source, - ...(Object.keys(sortTimestamps).length ? { sortTimestamps } : {}), - }; + return { workspaceId, ordering, source }; } /** Bind every observed timestamp before reading a new host catalog. Matching