diff --git a/docs/design/web-shell/web-shell-session-catalog-store.md b/docs/design/web-shell/web-shell-session-catalog-store.md new file mode 100644 index 00000000000..cad3dc69560 --- /dev/null +++ b/docs/design/web-shell/web-shell-session-catalog-store.md @@ -0,0 +1,41 @@ +# Web Shell session catalog store + +## Goal + +Share session-list reads within one Web Shell page so overlapping surfaces do +not independently scan the same daemon catalogs. Preserve the existing REST +route ownership, public APIs, list metadata, and user-visible refresh cadence. + +## Design + +Each `DaemonClient` owns one Web Shell-internal `SessionCatalogStore` through a +`WeakMap`. A query is identified by its legacy or qualified route, exact +workspace cwd, and every list option that affects the wire request. Entries +cache the complete `DaemonSessionListPage`, share in-flight requests, retain the +last successful page on errors, and remain available for 30 seconds after their +last subscriber leaves. + +The client-wide scheduler allows two concurrent list requests. Background +initial loads, polls, and delayed refreshes may consume only one slot, leaving +one available for explicit reloads, mutations, and user-blocking fresh reads. +Revision numbers prevent a request that began before an invalidation from +overwriting newer state. A fresh read always waits for a request that starts +after that read was requested. + +Polling registrations are attached to catalog subscriptions. Identical queries +use the shortest requested interval. Background work pauses while the document +is hidden and overdue work resumes when it becomes visible. Automatic failures +retain their last page and retry no sooner than 30 seconds; explicit work +bypasses that backoff. + +Mutation and session lifecycle callers invalidate by explicit workspace cwd. +Only confirmed rename and prompt-admission fields are patched locally. Creation +and turn completion also schedule one workspace-level refresh after two seconds +to cover daemon registration and persistence lag. Session ids are never treated +as globally unique across workspaces. + +## Boundaries + +This changes only Web Shell. It does not modify daemon routes, SDK APIs, generic +WebUI resource hooks, group/status/Git polling, cross-tab behavior, pagination +policy, or introduce push-based catalog events. diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 8bb996c94cb..bd86103ff34 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { DaemonHttpError, type DaemonInputAnnotation, + type DaemonSessionSummary, type DaemonSessionContextUsageStatus, type DaemonSessionMonitorTaskStatus, type DaemonSessionShellTaskStatus, @@ -143,7 +144,6 @@ const { mockStore, mockFollowup, testState, - sidebarTokens, rawEnqueuePrompt, queuedTexts, editLastQueuedPrompt, @@ -159,6 +159,7 @@ const { rootWorkspaceProviders, qualifiedWorkspaceProviders, qualifiedSetWorkspaceSetting, + sessionCatalogController, } = vi.hoisted(() => { const connection: MockConnection = { status: 'connected', @@ -201,6 +202,12 @@ const { Promise.resolve({ workspaceCwd: '/tmp/project' }), ), listWorkspaceSessions: vi.fn(() => Promise.resolve([])), + createSideTaskSession: vi.fn().mockResolvedValue({ + sessionId: 'side-session-1', + clientId: 'side-client-1', + displayName: 'Side task', + }), + detachSession: vi.fn().mockResolvedValue(undefined), resolveSubagentSession: vi .fn() .mockRejectedValue(new Error('Subagent details unavailable')), @@ -218,6 +225,7 @@ const { attachSession: vi.fn().mockResolvedValue(undefined), clearSession: vi.fn().mockResolvedValue(undefined), releaseSession: vi.fn().mockResolvedValue(undefined), + renameSession: vi.fn().mockResolvedValue(undefined), recapSession: vi.fn().mockResolvedValue({ sessionId: 'session-1', recap: null, @@ -358,7 +366,6 @@ const { onOpenSession?: (sessionId: string) => void; } | null, }, - sidebarTokens: [] as Array, rawEnqueuePrompt: vi.fn(() => true), queuedTexts: [] as string[], editLastQueuedPrompt: vi.fn(() => false), @@ -374,6 +381,14 @@ const { rootWorkspaceProviders, qualifiedWorkspaceProviders, qualifiedSetWorkspaceSetting, + sessionCatalogController: { + invalidateWorkspace: vi.fn(), + sessionCreated: vi.fn(), + promptAdmitted: vi.fn(), + promptAdmissionUncertain: vi.fn(), + renamed: vi.fn(), + turnCompleted: vi.fn(), + }, }; }); @@ -794,7 +809,6 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => { const React = await import('react'); return { WebShellSidebar: (props: { - sessionListReloadToken?: number; collapsed?: boolean; onOpenPlugins?: () => void; onOpenChannels?: () => void; @@ -805,7 +819,6 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => { onLoadSession?: (sessionId: string) => Promise | void; onOpenAddWorkspace?: () => void; }) => { - sidebarTokens.push(props.sessionListReloadToken); // Expose the Daemon Status / Session Overview openers so tests can // exercise those activePanel branches (neither has a slash command). return React.createElement( @@ -891,6 +904,72 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => { }; }); +vi.mock('./session-catalog/session-catalog-store', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('./session-catalog/session-catalog-store') + >(); + type TestListClient = { + listWorkspaceSessions?: ( + workspaceCwd: string, + options?: Record, + ) => Promise; + listWorkspaceSessionsPage?: ( + workspaceCwd: string, + options?: Record, + ) => Promise<{ sessions: DaemonSessionSummary[] }>; + workspaceByCwd: (workspaceCwd: string) => { + listWorkspaceSessions?: ( + options?: Record, + ) => Promise; + listWorkspaceSessionsPage?: ( + options?: Record, + ) => Promise<{ sessions: DaemonSessionSummary[] }>; + }; + }; + return { + ...actual, + loadSessionCatalogOnce: async ( + client: TestListClient, + query: { + routeKind: 'legacy' | 'qualified'; + workspaceCwd: string; + options?: Record; + }, + ) => { + if (query.routeKind === 'qualified') { + const scoped = client.workspaceByCwd(query.workspaceCwd); + if (scoped.listWorkspaceSessionsPage) { + return scoped.listWorkspaceSessionsPage(query.options); + } + return { + sessions: scoped.listWorkspaceSessions + ? await scoped.listWorkspaceSessions(query.options) + : [], + }; + } + if (client.listWorkspaceSessionsPage) { + return client.listWorkspaceSessionsPage( + query.workspaceCwd, + query.options, + ); + } + return { + sessions: client.listWorkspaceSessions + ? await client.listWorkspaceSessions( + query.workspaceCwd, + query.options, + ) + : [], + }; + }, + }; +}); + +vi.mock('./session-catalog/session-catalog-hooks', () => ({ + useSessionCatalogController: () => sessionCatalogController, +})); + vi.mock('./components/dialogs/AddWorkspaceDialog', async () => { const React = await import('react'); return { @@ -4293,6 +4372,15 @@ beforeEach(() => { }); mockWorkspace.client.listWorkspaceSessions.mockReset(); mockWorkspace.client.listWorkspaceSessions.mockResolvedValue([]); + mockWorkspace.client.createSideTaskSession.mockReset(); + mockWorkspace.client.createSideTaskSession.mockImplementation( + () => new Promise(() => undefined), + ); + mockWorkspace.client.detachSession.mockReset(); + mockWorkspace.client.detachSession.mockResolvedValue(undefined); + for (const method of Object.values(sessionCatalogController)) { + method.mockReset(); + } mockWorkspace.client.resolveSubagentSession.mockReset(); mockWorkspace.client.resolveSubagentSession.mockRejectedValue( new Error('Subagent details unavailable'), @@ -4326,7 +4414,6 @@ beforeEach(() => { testState.latestSettingsState = null; testState.latestScheduledTasksProps = null; testState.latestGoalsProps = null; - sidebarTokens.length = 0; rawEnqueuePrompt.mockClear(); editorClear.mockClear(); editorCommit.mockClear(); @@ -4375,6 +4462,7 @@ beforeEach(() => { mockSessionActions.attachSession.mockResolvedValue(undefined); mockSessionActions.clearSession.mockResolvedValue(undefined); mockSessionActions.releaseSession.mockResolvedValue(undefined); + mockSessionActions.renameSession.mockResolvedValue(undefined); mockSessionActions.recapSession.mockResolvedValue({ sessionId: 'session-1', recap: null, @@ -4906,6 +4994,9 @@ describe('App shell command queueing', () => { }); expect(accepted).toBe(true); expect(mockSessionActions.createSession).not.toHaveBeenCalled(); + expect(sessionCatalogController.invalidateWorkspace).toHaveBeenCalledWith( + '/tmp/project', + ); }); it('blocks duplicate ! submission while session creation is in flight', async () => { @@ -7788,14 +7879,61 @@ describe('App session callbacks', () => { await vi.waitFor(() => { expect(mockWorkspace.client.listWorkspaceSessions).toHaveBeenCalled(); }); - mockWorkspace.client.listWorkspaceSessions.mockResolvedValue([ + mockWorkspace.client.listWorkspaceSessions + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + displayName: 'Generated session title', + }, + ]); + vi.useFakeTimers(); + + act(() => { + testState.streamingState = 'responding'; + rerender(); + }); + act(() => { + testState.streamingState = 'idle'; + rerender(); + }); + await act(async () => { + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="chat-context-header"]') + ?.textContent, + ).not.toContain('Generated session title'); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + + expect( + container.querySelector('[data-testid="chat-context-header"]') + ?.textContent, + ).toContain('Generated session title'); + }); + + it('defers title refresh while the page is hidden', async () => { + mockConnection.displayName = undefined; + const { container, rerender } = renderApp(); + await vi.waitFor(() => { + expect(mockWorkspace.client.listWorkspaceSessions).toHaveBeenCalled(); + }); + mockWorkspace.client.listWorkspaceSessions.mockClear(); + mockWorkspace.client.listWorkspaceSessions.mockResolvedValueOnce([ { sessionId: 'session-1', workspaceCwd: '/tmp/project', - displayName: 'Generated session title', + displayName: 'Visible session title', }, ]); vi.useFakeTimers(); + Object.defineProperty(document, 'hidden', { + configurable: true, + value: true, + }); act(() => { testState.streamingState = 'responding'; @@ -7805,14 +7943,32 @@ describe('App session callbacks', () => { testState.streamingState = 'idle'; rerender(); }); + await act(async () => { + await Promise.resolve(); + }); + expect(mockWorkspace.client.listWorkspaceSessions).not.toHaveBeenCalled(); await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + expect(mockWorkspace.client.listWorkspaceSessions).not.toHaveBeenCalled(); + expect( + container.querySelector('[data-testid="chat-context-header"]') + ?.textContent, + ).not.toContain('Visible session title'); + + Object.defineProperty(document, 'hidden', { + configurable: true, + value: false, + }); + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')); + await Promise.resolve(); + }); expect( container.querySelector('[data-testid="chat-context-header"]') ?.textContent, - ).toContain('Generated session title'); + ).toContain('Visible session title'); }); it('submits through a disconnected session when prompt SSE restart is enabled', async () => { @@ -8568,6 +8724,18 @@ describe('App session callbacks', () => { expect(mockSessionActions.createSession).toHaveBeenCalledWith( expect.objectContaining({ workspaceCwd: '/tmp/project' }), ); + const promptOptions = mockSessionActions.sendPrompt.mock.calls.at( + -1, + )?.[1] as { onAdmitted?: () => void } | undefined; + act(() => promptOptions?.onAdmitted?.()); + expect(sessionCatalogController.promptAdmitted).toHaveBeenCalledWith( + '/tmp/project', + expect.any(String), + ); + expect(sessionCatalogController.promptAdmitted).not.toHaveBeenCalledWith( + '/work/secondary', + expect.any(String), + ); }); it('revalidates a draft workspace before its cleanup effect runs', async () => { @@ -8608,6 +8776,18 @@ describe('App session callbacks', () => { expect(mockSessionActions.createSession).toHaveBeenCalledWith( expect.objectContaining({ workspaceCwd: '/tmp/project' }), ); + const promptOptions = mockSessionActions.sendPrompt.mock.calls.at( + -1, + )?.[1] as { onAdmitted?: () => void } | undefined; + act(() => promptOptions?.onAdmitted?.()); + expect(sessionCatalogController.promptAdmitted).toHaveBeenCalledWith( + '/tmp/project', + expect.any(String), + ); + expect(sessionCatalogController.promptAdmitted).not.toHaveBeenCalledWith( + '/work/secondary', + expect.any(String), + ); }); it('does not start a new chat when selecting the active workspace', async () => { @@ -9750,8 +9930,7 @@ describe('App session callbacks', () => { expect(activeGoals.at(-1)).toBeNull(); }); - it('gates direct submissions and dispatches submit events with delayed sidebar reload', async () => { - vi.useFakeTimers(); + it('gates direct submissions and dispatches compatible submit events', async () => { const onSubmitBefore = vi.fn().mockResolvedValue(undefined); const onSessionChange = vi.fn(); const { container } = renderApp({ onSubmitBefore, onSessionChange }); @@ -9777,12 +9956,31 @@ describe('App session callbacks', () => { prompt: 'hello', queued: false, }); + const promptOptions = mockSessionActions.sendPrompt.mock.calls.at( + -1, + )?.[1] as { onAdmitted?: () => void } | undefined; + act(() => promptOptions?.onAdmitted?.()); + expect(sessionCatalogController.promptAdmitted).toHaveBeenCalledWith( + '/tmp/project', + 'session-1', + ); + }); - const tokenAfterSubmit = sidebarTokens.at(-1); - act(() => { - vi.advanceTimersByTime(2000); - }); - expect(sidebarTokens.at(-1)).not.toBe(tokenAfterSubmit); + it('does not attribute prompt admission when the active owner is unknown', async () => { + mockConnection.workspaceCwd = undefined; + const { container } = renderApp(); + await flush(); + + await clickSubmit(container); + await flush(); + + expect(mockSessionActions.sendPrompt).toHaveBeenCalled(); + const promptOptions = mockSessionActions.sendPrompt.mock.calls.at( + -1, + )?.[1] as { onAdmitted?: () => void } | undefined; + act(() => promptOptions?.onAdmitted?.()); + + expect(sessionCatalogController.promptAdmitted).not.toHaveBeenCalled(); }); it('dispatches direct and queued submit events for image-only prompts', async () => { @@ -10670,6 +10868,10 @@ describe('App session callbacks', () => { expect(mockSessionActions.sendPrompt).toHaveBeenCalled(); }); expect(commitAccepted).toHaveBeenCalledOnce(); + expect(sessionCatalogController.sessionCreated).toHaveBeenCalledWith( + '/workspace', + 'session-created', + ); }); it('lets a selected session bypass a stale preparation promise', async () => { @@ -10976,6 +11178,10 @@ describe('App session callbacks', () => { container.querySelector('[data-testid="prompt-admission-unknown"]'), ).not.toBeNull(); expect(testState.latestChatEditorProps?.disabled).toBe(true); + expect( + sessionCatalogController.promptAdmissionUncertain, + ).toHaveBeenCalledWith('/tmp/project'); + expect(sessionCatalogController.promptAdmitted).not.toHaveBeenCalled(); warn.mockRestore(); }); @@ -11356,6 +11562,9 @@ describe('App session callbacks', () => { message: 'Turn error (block turn-error-1)', }), }); + expect(sessionCatalogController.turnCompleted).toHaveBeenCalledWith( + '/tmp/project', + ); onSessionChange.mockClear(); act(() => { @@ -11371,6 +11580,51 @@ describe('App session callbacks', () => { expect(onSessionChange).not.toHaveBeenCalledWith( expect.objectContaining({ type: 'turn_complete' }), ); + + sessionCatalogController.turnCompleted.mockClear(); + act(() => { + mockConnection.sessionId = 'same-session'; + mockConnection.workspaceCwd = '/tmp/project'; + testState.streamingState = 'responding'; + rerender({ onSessionChange }); + }); + act(() => { + mockConnection.workspaceCwd = '/tmp/other'; + testState.streamingState = 'idle'; + rerender({ onSessionChange }); + }); + expect(sessionCatalogController.turnCompleted).not.toHaveBeenCalled(); + }); + + it('captures a main-session workspace that becomes available mid-turn', async () => { + mockConnection.sessionId = 'session-late'; + mockConnection.workspaceCwd = undefined; + const onSessionChange = vi.fn(); + const { rerender } = renderApp({ onSessionChange }); + await flush(); + + act(() => { + testState.streamingState = 'responding'; + rerender({ onSessionChange }); + }); + act(() => { + mockConnection.workspaceCwd = '/tmp/project'; + rerender({ onSessionChange }); + }); + act(() => { + testState.streamingState = 'idle'; + rerender({ onSessionChange }); + }); + + expect(sessionCatalogController.turnCompleted).toHaveBeenCalledWith( + '/tmp/project', + ); + expect(onSessionChange).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'turn_complete', + sessionId: 'session-late', + }), + ); }); it('auto-closes an open Settings/Status panel when a tool approval becomes pending', async () => { @@ -12287,6 +12541,11 @@ describe('App session callbacks', () => { it('creates a side task from the external shell ref', async () => { mockConnection.capabilities.features = ['session_side_task']; + mockWorkspace.client.createSideTaskSession.mockResolvedValueOnce({ + sessionId: 'side-session-1', + clientId: 'side-client-1', + displayName: 'Side task', + }); const shellRef = createRef(); const { container } = renderApp({ shellRef }); await flush(); @@ -12298,6 +12557,11 @@ describe('App session callbacks', () => { expect(created).toBe(true); expect(container.querySelector('button[title="Side task"]')).not.toBeNull(); + await flush(); + expect(sessionCatalogController.sessionCreated).toHaveBeenCalledWith( + '/tmp/project', + 'side-session-1', + ); }); it('opens the Session Overview from the external shell ref like the sidebar button', async () => { @@ -14142,6 +14406,44 @@ describe('App session callbacks', () => { ).toBe(true); }); + it('resynchronizes the catalog when a settings prompt admission is ambiguous', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const lostResponse = deferred(); + mockSessionActions.sendPrompt.mockImplementationOnce((_text, options) => { + options?.onAdmissionStarted?.(); + return lostResponse.promise; + }); + const { container } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + + await act(async () => { + container + .querySelector( + '[data-testid="change-language-workspace"]', + ) + ?.click(); + await Promise.resolve(); + }); + await vi.waitFor(() => { + expect(mockSessionActions.sendPrompt).toHaveBeenCalledOnce(); + }); + + await act(async () => { + lostResponse.reject(new Error('response lost after admission started')); + await Promise.resolve(); + }); + + expect( + sessionCatalogController.promptAdmissionUncertain, + ).toHaveBeenCalledOnce(); + expect( + sessionCatalogController.promptAdmissionUncertain, + ).toHaveBeenCalledWith('/tmp/project'); + }); + it('marks the chat view aria-hidden while a panel is shown', async () => { const { container } = renderApp(); await flush(); @@ -14201,6 +14503,11 @@ describe('App session callbacks', () => { sessionId: 'session-1', newName: 'Renamed Session', }); + expect(sessionCatalogController.renamed).toHaveBeenCalledWith( + '/tmp/project', + 'session-1', + 'Renamed Session', + ); onSessionChange.mockClear(); act(() => { @@ -14208,6 +14515,127 @@ describe('App session callbacks', () => { }); expect(onSessionChange).not.toHaveBeenCalled(); }); + + it('does not report an existing title loaded during a session switch as a rename', async () => { + const onSessionChange = vi.fn(); + const { rerender } = renderApp({ onSessionChange }); + await flush(); + + act(() => { + mockConnection.sessionId = 'session-2'; + mockConnection.displayName = undefined; + rerender({ onSessionChange }); + }); + act(() => { + mockConnection.displayName = 'Existing Session'; + rerender({ onSessionChange }); + }); + + expect(sessionCatalogController.renamed).not.toHaveBeenCalled(); + expect(onSessionChange).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'rename' }), + ); + }); + + it('does not report an existing title when the same session id changes workspace', async () => { + const onSessionChange = vi.fn(); + const { rerender } = renderApp({ onSessionChange }); + await flush(); + + act(() => { + mockConnection.workspaceCwd = '/tmp/other'; + mockConnection.displayName = 'Existing Other Session'; + rerender({ onSessionChange }); + }); + + expect(sessionCatalogController.renamed).not.toHaveBeenCalled(); + expect(onSessionChange).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'rename' }), + ); + }); + + it('handles a rename event before the session workspace is known', async () => { + mockConnection.workspaceCwd = undefined; + const onSessionChange = vi.fn(); + const { rerender } = renderApp({ onSessionChange }); + await flush(); + + act(() => { + mockConnection.displayName = 'Renamed before workspace'; + rerender({ onSessionChange }); + }); + + expect(sessionCatalogController.renamed).not.toHaveBeenCalled(); + expect(onSessionChange).toHaveBeenCalledWith({ + type: 'rename', + sessionId: 'session-1', + newName: 'Renamed before workspace', + }); + }); + + it('patches and resynchronizes the catalog after a confirmed /rename', async () => { + const onSessionChange = vi.fn(); + const { container, rerender } = renderApp({ onSessionChange }); + await flush(); + + testState.prompt = '/rename Catalog title'; + await clickSubmit(container); + await flush(); + + expect(mockSessionActions.renameSession).toHaveBeenCalledWith( + 'Catalog title', + ); + expect(sessionCatalogController.renamed).toHaveBeenCalledWith( + '/tmp/project', + 'session-1', + 'Catalog title', + ); + expect(onSessionChange).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'rename' }), + ); + + act(() => { + mockConnection.displayName = 'Catalog title'; + rerender({ onSessionChange }); + }); + expect(sessionCatalogController.renamed).toHaveBeenCalledTimes(1); + expect(onSessionChange).toHaveBeenCalledWith({ + type: 'rename', + sessionId: 'session-1', + newName: 'Catalog title', + }); + }); + + it('reconciles a name reused after the session loaded a different title', async () => { + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/rename Reused title'; + await clickSubmit(container); + await flush(); + + act(() => { + mockConnection.sessionId = 'session-2'; + mockConnection.displayName = 'Other session'; + rerender(); + }); + act(() => { + mockConnection.sessionId = 'session-1'; + mockConnection.displayName = 'Externally renamed'; + rerender(); + }); + sessionCatalogController.renamed.mockClear(); + + testState.prompt = '/rename Reused title'; + await clickSubmit(container); + await flush(); + + expect(sessionCatalogController.renamed).toHaveBeenCalledWith( + '/tmp/project', + 'session-1', + 'Reused title', + ); + }); }); describe('App prompt send failure retry', () => { @@ -14273,6 +14701,9 @@ describe('App prompt send failure retry', () => { expect( document.querySelector('[data-testid="prompt-admission-unknown"]'), ).not.toBeNull(); + expect( + sessionCatalogController.promptAdmissionUncertain, + ).toHaveBeenCalledWith('/workspace'); }); it('locks duplicate submission when prompt admission is unknown', async () => { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 1b5a21e93dc..53cd8600092 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -66,6 +66,11 @@ import { type VoiceStatusRevision, } from './voice/voice-workspace-target'; import { useVoiceWorkspaceSettings } from './voice/use-voice-workspace-settings'; +import { useSessionCatalogController } from './session-catalog/session-catalog-hooks'; +import { + loadSessionCatalogOnce, + SESSION_CATALOG_TRAILING_REFRESH_MS, +} from './session-catalog/session-catalog-store'; import { useLiveVoiceSetup } from './live/useLiveVoiceSetup'; import { ChatEditor, @@ -1869,6 +1874,9 @@ export function App({ const connection = useConnection(); const transcriptHistory = useTranscriptHistory(); const workspace = useWorkspace(); + const sessionCatalogController = useSessionCatalogController( + workspace.client, + ); const refreshWorkspaceCapabilities = workspace.refreshCapabilities; const workspaces = useMemo(() => { const capabilityWorkspaces = workspace.capabilities?.workspaces ?? []; @@ -2063,11 +2071,18 @@ export function App({ setSessionBranch(summary.branch); setSessionStatusDisplayName(summary.displayName); } - return workspace.client - .listWorkspaceSessions(summary.workspaceCwd, { pageSize: 200 }) - .then((sessions) => { + return loadSessionCatalogOnce( + workspace.client, + { + routeKind: 'legacy', + workspaceCwd: summary.workspaceCwd, + options: { pageSize: 200 }, + }, + { fresh: true }, + ) + .then((page) => { if (worktreeSessionIdRef.current !== sid) return; - const listedSession = sessions.find( + const listedSession = page.sessions.find( (session) => session.sessionId === sid, ); setSessionStatusDisplayName( @@ -2617,6 +2632,7 @@ export function App({ }, [createSideTask, pushToast, t]); const createSideTaskSession = useCallback( async (_tabId: string, parentSessionId: string, title: string) => { + const ownerCwd = connection.workspaceCwd; const parentClientId = connection.sessionId === parentSessionId ? connection.clientId @@ -2628,6 +2644,9 @@ export function App({ }, parentClientId, ); + if (ownerCwd) { + sessionCatalogController.sessionCreated(ownerCwd, session.sessionId); + } await workspace.client .detachSession(session.sessionId, session.clientId) .catch(() => undefined); @@ -2636,7 +2655,13 @@ export function App({ displayName: session.displayName, }; }, - [connection.clientId, connection.sessionId, workspace.client], + [ + connection.clientId, + connection.sessionId, + connection.workspaceCwd, + sessionCatalogController, + workspace.client, + ], ); const handleSideTaskCreated = useCallback( (tabId: string, sessionId: string) => { @@ -2773,16 +2798,23 @@ export function App({ : { parentSessionId, items: [], loaded: false }, ); let cancelled = false; - void workspace.client - .listWorkspaceSessions(workspaceCwd, { - pageSize: SESSION_LIST_PAGE_SIZE, - archiveState: 'active', - sourceType: WEB_SHELL_SIDE_TASK_SOURCE_TYPE, - sourceId: parentSessionId, - }) - .then((sessions) => { + void loadSessionCatalogOnce( + workspace.client, + { + routeKind: 'legacy', + workspaceCwd, + options: { + pageSize: SESSION_LIST_PAGE_SIZE, + archiveState: 'active', + sourceType: WEB_SHELL_SIDE_TASK_SOURCE_TYPE, + sourceId: parentSessionId, + }, + }, + { fresh: true }, + ) + .then((page) => { if (cancelled) return; - const listedItems = sessions.map((session) => ({ + const listedItems = page.sessions.map((session) => ({ sessionId: session.sessionId, title: session.displayName?.trim() || @@ -4521,17 +4553,23 @@ export function App({ const activeConnection = connectionRef.current; if (!activeConnection.sessionId || !activeConnection.workspaceCwd) return; try { - const sessions = await workspace.client.listWorkspaceSessions( - activeConnection.workspaceCwd, - { pageSize: 200 }, + const page = await loadSessionCatalogOnce( + workspace.client, + { + routeKind: 'legacy', + workspaceCwd: activeConnection.workspaceCwd, + options: { pageSize: 200 }, + }, + { fresh: true }, ); if ( connectionRef.current.sessionId !== activeConnection.sessionId || + connectionRef.current.workspaceCwd !== activeConnection.workspaceCwd || connectionRef.current.displayName ) { return; } - const displayName = sessions.find( + const displayName = page.sessions.find( (session) => session.sessionId === activeConnection.sessionId, )?.displayName; if (displayName?.trim()) setSessionStatusDisplayName(displayName); @@ -4543,6 +4581,59 @@ export function App({ refreshActiveSessionDisplayName, ); refreshActiveSessionDisplayNameRef.current = refreshActiveSessionDisplayName; + const delayedDisplayNameRefreshTimerRef = useRef | null>(null); + const pendingDisplayNameRefreshRef = useRef< + { sessionId: string; workspaceCwd: string } | undefined + >(undefined); + const refreshDisplayNameIfCurrent = useCallback( + (sessionId: string, workspaceCwd: string) => { + const activeConnection = connectionRef.current; + if ( + activeConnection.sessionId !== sessionId || + activeConnection.workspaceCwd !== workspaceCwd || + activeConnection.displayName + ) { + return; + } + void refreshActiveSessionDisplayNameRef.current(); + }, + [], + ); + const scheduleDelayedActiveSessionDisplayNameRefresh = useCallback( + (sessionId: string, workspaceCwd: string) => { + pendingDisplayNameRefreshRef.current = undefined; + if (delayedDisplayNameRefreshTimerRef.current !== null) { + clearTimeout(delayedDisplayNameRefreshTimerRef.current); + } + delayedDisplayNameRefreshTimerRef.current = setTimeout(() => { + delayedDisplayNameRefreshTimerRef.current = null; + if (typeof document !== 'undefined' && document.hidden) { + pendingDisplayNameRefreshRef.current = { sessionId, workspaceCwd }; + return; + } + refreshDisplayNameIfCurrent(sessionId, workspaceCwd); + }, SESSION_CATALOG_TRAILING_REFRESH_MS); + }, + [refreshDisplayNameIfCurrent], + ); + useEffect(() => { + const onVisibilityChange = () => { + if (document.hidden) return; + const pending = pendingDisplayNameRefreshRef.current; + if (!pending) return; + pendingDisplayNameRefreshRef.current = undefined; + refreshDisplayNameIfCurrent(pending.sessionId, pending.workspaceCwd); + }; + document.addEventListener('visibilitychange', onVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', onVisibilityChange); + if (delayedDisplayNameRefreshTimerRef.current !== null) { + clearTimeout(delayedDisplayNameRefreshTimerRef.current); + } + }; + }, [refreshDisplayNameIfCurrent]); const requireActiveSessionForLocalCommand = useCallback((): boolean => { if (connectionRef.current.sessionId) return true; pushToast('info', t('localCommand.noSession')); @@ -4561,6 +4652,13 @@ export function App({ null, ); const preparingSessionIdRef = useRef(null); + const allocatedSessionCatalogOwnerRef = useRef< + | { + sessionId: string; + workspaceCwd: string; + } + | undefined + >(undefined); /** Git mode intent for the next lazily-created session (branch or worktree). */ const [gitModeIntent, setGitModeIntent] = useState({ mode: 'current', @@ -4630,39 +4728,61 @@ export function App({ entry.cwd === requestedWorkspaceCwd && entry.trusted === true, )?.cwd : undefined; - await createAndAttachSessionForPrompt({ - sessionActions: sessionActions as typeof sessionActions & - SessionActionsWithCreate, - modelId, - modeId, - workspaceCwd: - lockedWorkspaceCwd ?? acceptedWorkspaceCwd ?? primaryWorkspaceCwd, - worktree: - gitModeIntentRef.current.mode === 'worktree' - ? { slug: gitModeIntentRef.current.slug } - : undefined, - branch: - gitModeIntentRef.current.mode === 'branch' - ? { name: gitModeIntentRef.current.name } - : undefined, - onSessionCreated: onSessionCreatedRef.current, - onSessionAllocated: (sessionId) => { - preparingSessionIdRef.current = sessionId; - allocatedSessionId = sessionId; - }, - getCurrentSessionId: () => connectionRef.current.sessionId, - }).then((result) => { - if (result.worktree) { - setSessionWorktree(result.worktree); - } - if (result.branch) { - setSessionBranch(result.branch); + const targetWorkspaceCwd = + lockedWorkspaceCwd ?? acceptedWorkspaceCwd ?? primaryWorkspaceCwd; + const catalogWorkspaceCwd = + targetWorkspaceCwd ?? + workspace.workspaceCwd ?? + connectionRef.current.workspaceCwd; + try { + await createAndAttachSessionForPrompt({ + sessionActions: sessionActions as typeof sessionActions & + SessionActionsWithCreate, + modelId, + modeId, + workspaceCwd: targetWorkspaceCwd, + worktree: + gitModeIntentRef.current.mode === 'worktree' + ? { slug: gitModeIntentRef.current.slug } + : undefined, + branch: + gitModeIntentRef.current.mode === 'branch' + ? { name: gitModeIntentRef.current.name } + : undefined, + onSessionCreated: onSessionCreatedRef.current, + onSessionAllocated: (sessionId) => { + preparingSessionIdRef.current = sessionId; + allocatedSessionId = sessionId; + if (catalogWorkspaceCwd) { + allocatedSessionCatalogOwnerRef.current = { + sessionId, + workspaceCwd: catalogWorkspaceCwd, + }; + sessionCatalogController.sessionCreated( + catalogWorkspaceCwd, + sessionId, + ); + } + }, + getCurrentSessionId: () => connectionRef.current.sessionId, + }).then((result) => { + if (result.worktree) { + setSessionWorktree(result.worktree); + } + if (result.branch) { + setSessionBranch(result.branch); + } + // Clear the pending intent only on success. On failure the + // composer chip stays in the selected mode so the user knows + // the intent was not fulfilled and can retry. + setGitModeIntent({ mode: 'current' }); + }); + } catch (error) { + if (allocatedSessionId && catalogWorkspaceCwd) { + sessionCatalogController.invalidateWorkspace(catalogWorkspaceCwd); } - // Clear the pending intent only on success. On failure the - // composer chip stays in the selected mode so the user knows - // the intent was not fulfilled and can retry. - setGitModeIntent({ mode: 'current' }); - }); + throw error; + } // One-shot: the picker targets only the *next* new session, so clear // it after creation. The next new chat defaults back to the primary // workspace unless the user picks one again. @@ -4678,7 +4798,13 @@ export function App({ }; void promise.then(clearPreparation, clearPreparation); return promise; - }, [lockedWorkspaceCwd, sessionActions, workspaces]); + }, [ + lockedWorkspaceCwd, + sessionActions, + sessionCatalogController, + workspace.workspaceCwd, + workspaces, + ]); const onSubmitBeforeRef = useRef(onSubmitBefore); onSubmitBeforeRef.current = onSubmitBefore; const onSlashCommandRef = useRef(onSlashCommand); @@ -4693,38 +4819,11 @@ export function App({ workspacesRef.current.find((entry) => entry.primary)?.cwd ); }, [lockedWorkspaceCwd]); - const [sessionListReloadToken, setSessionListReloadToken] = useState(0); - const delayedReloadTimerRef = useRef | null>( - null, - ); - useEffect( - () => () => { - if (delayedReloadTimerRef.current !== null) { - clearTimeout(delayedReloadTimerRef.current); - } - }, - [], - ); - // Daemon-side session registration lags behind the client, so schedule a - // backup reload ~2 s after the immediate one to pick up newly created sessions. - const scheduleDelayedSessionListReload = useCallback(() => { - if (delayedReloadTimerRef.current !== null) { - clearTimeout(delayedReloadTimerRef.current); - } - delayedReloadTimerRef.current = setTimeout(() => { - setSessionListReloadToken((n) => n + 1); - void refreshActiveSessionDisplayNameRef.current(); - }, 2000); - }, []); const dispatchSessionChange = useCallback( (event: SessionChangeEvent) => { onSessionChange?.(event); - setSessionListReloadToken((n) => n + 1); - if (event.type === 'turn_complete') { - scheduleDelayedSessionListReload(); - } }, - [onSessionChange, scheduleDelayedSessionListReload], + [onSessionChange], ); // Ref-stable handle so that useCallback hooks (sendPrompt, enqueuePrompt, // turn_complete effect) don't need dispatchSessionChange in their dep arrays. @@ -4815,6 +4914,7 @@ export function App({ setIsPreparingPrompt(true); } clearFollowup(); + const existingSessionWorkspaceCwd = getComposerWorkspaceCwd(); let allocatedSessionId: string | undefined; try { allocatedSessionId = await ensureSessionForPrompt(); @@ -4823,21 +4923,6 @@ export function App({ setIsPreparingPrompt(false); } } - const promptOptions: SendPromptOptionsWithRetry = { - images, - inputAnnotations: opts?.inputAnnotations, - optimisticUserMessage: opts?.optimisticUserMessage, - retry: opts?.retry, - ...(opts?.onAdmissionStarted - ? { - onAdmissionStarted: () => - opts.onAdmissionStarted?.( - connectionRef.current.sessionId ?? allocatedSessionId, - ), - } - : {}), - ...(opts?.onAdmitted ? { onAdmitted: opts.onAdmitted } : {}), - }; if (opts?.commitComposerAccepted) { opts.commitComposerAccepted(); } else if (opts?.clearComposerOnPromptStart) { @@ -4845,6 +4930,36 @@ export function App({ } const sessionIdAfterEnsure = connectionRef.current.sessionId ?? allocatedSessionId; + const allocatedOwner = allocatedSessionCatalogOwnerRef.current; + const promptWorkspaceCwd = allocatedSessionId + ? allocatedOwner?.sessionId === allocatedSessionId + ? allocatedOwner.workspaceCwd + : undefined + : existingSessionWorkspaceCwd; + let admissionStarted = false; + let admitted = false; + const promptOptions: SendPromptOptionsWithRetry = { + images, + inputAnnotations: opts?.inputAnnotations, + optimisticUserMessage: opts?.optimisticUserMessage, + retry: opts?.retry, + onAdmissionStarted: () => { + admissionStarted = true; + opts?.onAdmissionStarted?.( + connectionRef.current.sessionId ?? allocatedSessionId, + ); + }, + onAdmitted: () => { + admitted = true; + if (sessionIdAfterEnsure && promptWorkspaceCwd) { + sessionCatalogController.promptAdmitted( + promptWorkspaceCwd, + sessionIdAfterEnsure, + ); + } + opts?.onAdmitted?.(); + }, + }; if (sessionIdAfterEnsure && (text.trim() || (images?.length ?? 0) > 0)) { dispatchSessionChangeRef.current?.({ type: 'submit', @@ -4852,7 +4967,6 @@ export function App({ prompt: text, queued: false, }); - scheduleDelayedSessionListReload(); } const previousUserMessageId = opts?.onOptimisticUserMessage ? getLatestUserBlockId(store.getSnapshot().blocks) @@ -4876,13 +4990,25 @@ export function App({ }); } } - return await resultPromise; + try { + return await resultPromise; + } catch (error) { + if ( + admissionStarted && + !admitted && + !isDefinitelyRejectedPromptAdmission(error) && + promptWorkspaceCwd + ) { + sessionCatalogController.promptAdmissionUncertain(promptWorkspaceCwd); + } + throw error; + } }, [ clearFollowup, ensureSessionForPrompt, getComposerWorkspaceCwd, - scheduleDelayedSessionListReload, + sessionCatalogController, sessionActions, store, ], @@ -4901,15 +5027,22 @@ export function App({ return connection.sessionId; } // Fetch the most recent session for this workspace. - const sessions = await workspace.client - .workspaceByCwd(cwd) - .listWorkspaceSessions({ pageSize: 1, archiveState: 'active' }); - if (sessions.length > 0) return sessions[0].sessionId; + const page = await loadSessionCatalogOnce( + workspace.client, + { + routeKind: 'qualified', + workspaceCwd: cwd, + options: { pageSize: 1, archiveState: 'active' }, + }, + { fresh: true }, + ); + if (page.sessions.length > 0) return page.sessions[0].sessionId; } // No session exists or forced: create one. const result = await ( sessionActions as typeof sessionActions & SessionActionsWithCreate ).createSession({ workspaceCwd: cwd }); + sessionCatalogController.sessionCreated(cwd, result.sessionId); return result.sessionId; } catch { return undefined; @@ -4918,6 +5051,7 @@ export function App({ [ connection.sessionId, activeWorkspaceCwd, + sessionCatalogController, workspace.client, sessionActions, sessionWorktree, @@ -5226,6 +5360,11 @@ export function App({ prompt: text, queued: true, }); + if (sourceWorkspaceCwd) { + sessionCatalogController.invalidateWorkspace( + sourceWorkspaceCwd, + ); + } } }) .catch((err: unknown) => { @@ -5250,10 +5389,14 @@ export function App({ prompt: text, queued: true, }); + const workspaceCwd = getComposerWorkspaceCwd(); + if (workspaceCwd) { + sessionCatalogController.invalidateWorkspace(workspaceCwd); + } } return result; }, - [getComposerWorkspaceCwd, rawEnqueuePrompt], + [getComposerWorkspaceCwd, rawEnqueuePrompt, sessionCatalogController], ); useEffect(() => { @@ -6023,6 +6166,7 @@ export function App({ isDrainingRef.current = true; const generation = ++drainGenerationRef.current; const drainSessionId = connectionRef.current.sessionId; + const drainWorkspaceCwd = getComposerWorkspaceCwd(); void (async () => { try { let batch = cmds; @@ -6058,6 +6202,10 @@ export function App({ error, `Failed to execute shell command: !${batch[i]}`, ); + } finally { + if (drainWorkspaceCwd) { + sessionCatalogController.invalidateWorkspace(drainWorkspaceCwd); + } } } batch = queuedShellCommandsRef.current; @@ -6072,7 +6220,15 @@ export function App({ } } })(); - }, [streamingState, sessionActions, reportError, pushToast, t]); + }, [ + getComposerWorkspaceCwd, + pushToast, + reportError, + sessionActions, + sessionCatalogController, + streamingState, + t, + ]); useEffect(() => { let retryableTurnErrorId: string | null = null; @@ -6106,29 +6262,68 @@ export function App({ // so that within the same render, the ref is already updated before we read it. const prevStreamingForTurnCompleteRef = useRef(streamingState); const streamingSessionIdRef = useRef(undefined); + const streamingWorkspaceCwdRef = useRef(undefined); useEffect(() => { const prev = prevStreamingForTurnCompleteRef.current; prevStreamingForTurnCompleteRef.current = streamingState; if (streamingState !== 'idle') { - streamingSessionIdRef.current = connectionRef.current.sessionId; + if (prev === 'idle' || streamingSessionIdRef.current === undefined) { + streamingSessionIdRef.current = connection.sessionId; + streamingWorkspaceCwdRef.current = connection.workspaceCwd; + } else if ( + connection.sessionId === streamingSessionIdRef.current && + streamingWorkspaceCwdRef.current === undefined + ) { + streamingWorkspaceCwdRef.current = connection.workspaceCwd; + } } if (prev !== 'idle' && streamingState === 'idle') { const sessionId = connectionRef.current.sessionId; + const workspaceCwd = connectionRef.current.workspaceCwd; // Only fire if the session that was streaming is still the active one. // Session switches reset streamingState to idle, which must not produce // a spurious turn_complete for the new session. - if (!sessionId || sessionId !== streamingSessionIdRef.current) return; + if ( + !sessionId || + sessionId !== streamingSessionIdRef.current || + workspaceCwd !== streamingWorkspaceCwdRef.current + ) { + return; + } const turnError = retryableTurnErrorIdRef.current != null ? new Error(`Turn error (block ${retryableTurnErrorIdRef.current})`) : undefined; + if (workspaceCwd) { + sessionCatalogController.turnCompleted(workspaceCwd); + if (!connectionRef.current.displayName) { + scheduleDelayedActiveSessionDisplayNameRefresh( + sessionId, + workspaceCwd, + ); + if (typeof document !== 'undefined' && document.hidden) { + pendingDisplayNameRefreshRef.current = { + sessionId, + workspaceCwd, + }; + } else { + void refreshActiveSessionDisplayNameRef.current(); + } + } + } dispatchSessionChangeRef.current?.({ type: 'turn_complete', sessionId, error: turnError, }); } - }, [streamingState]); + }, [ + connection.sessionId, + connection.workspaceCwd, + scheduleDelayedActiveSessionDisplayNameRefresh, + sessionCatalogController, + streamingState, + ]); useEffect(() => { onConnectionChange?.(connection.status); @@ -6210,24 +6405,76 @@ export function App({ ]); const lastRenameSessionRef = useRef(undefined); + const lastRenameWorkspaceCwdRef = useRef(undefined); const lastRenameNameRef = useRef(undefined); + const lastReconciledRenameRef = useRef< + | { + workspaceCwd?: string; + sessionId: string; + displayName: string; + } + | undefined + >(undefined); + const reconcileCatalogRename = useCallback( + ( + workspaceCwd: string | undefined, + sessionId: string, + displayName: string, + ) => { + lastReconciledRenameRef.current = { + workspaceCwd, + sessionId, + displayName, + }; + if (workspaceCwd) { + sessionCatalogController.renamed(workspaceCwd, sessionId, displayName); + } + }, + [sessionCatalogController], + ); useEffect(() => { const sessionId = connection.sessionId; const displayName = connection.displayName; if (!sessionId || !displayName) return; - if (sessionId !== lastRenameSessionRef.current) { + if ( + sessionId !== lastRenameSessionRef.current || + connection.workspaceCwd !== lastRenameWorkspaceCwdRef.current + ) { lastRenameSessionRef.current = sessionId; + lastRenameWorkspaceCwdRef.current = connection.workspaceCwd; lastRenameNameRef.current = displayName; + lastReconciledRenameRef.current = undefined; return; } if (displayName === lastRenameNameRef.current) return; lastRenameNameRef.current = displayName; + const reconciled = lastReconciledRenameRef.current; + lastReconciledRenameRef.current = undefined; + const alreadyReconciled = + reconciled !== undefined && + reconciled.workspaceCwd === connection.workspaceCwd && + reconciled.sessionId === sessionId && + reconciled.displayName === displayName; + if (!alreadyReconciled) { + if (connection.workspaceCwd) { + sessionCatalogController.renamed( + connection.workspaceCwd, + sessionId, + displayName, + ); + } + } dispatchSessionChangeRef.current?.({ type: 'rename', sessionId, newName: displayName, }); - }, [connection.sessionId, connection.displayName]); + }, [ + connection.displayName, + connection.sessionId, + connection.workspaceCwd, + sessionCatalogController, + ]); useEffect(() => { const nextGoal = getLatestActiveGoalFromBlocks(blocks); @@ -8059,9 +8306,18 @@ export function App({ return true; } if (!requireActiveSessionForLocalCommand()) return false; + const renamedSessionId = connectionRef.current.sessionId; + const renamedWorkspaceCwd = connectionRef.current.workspaceCwd; sessionActions .renameSession(displayName) .then(() => { + if (renamedSessionId) { + reconcileCatalogRename( + renamedWorkspaceCwd, + renamedSessionId, + displayName, + ); + } store.dispatch([ { type: 'status', @@ -8070,6 +8326,11 @@ export function App({ ]); }) .catch((error: unknown) => { + if (renamedWorkspaceCwd) { + sessionCatalogController.invalidateWorkspace( + renamedWorkspaceCwd, + ); + } reportError(error, 'Failed to rename session'); }); return true; @@ -8303,9 +8564,18 @@ export function App({ prompt: `!${cmd}`, queued: false, }); - scheduleDelayedSessionListReload(); } - return sessionActions.sendShellCommand(cmd); + const allocatedOwner = allocatedSessionCatalogOwnerRef.current; + const workspaceCwd = createdSessionId + ? allocatedOwner?.sessionId === createdSessionId + ? allocatedOwner.workspaceCwd + : undefined + : getComposerWorkspaceCwd(); + return sessionActions.sendShellCommand(cmd).finally(() => { + if (workspaceCwd) { + sessionCatalogController.invalidateWorkspace(workspaceCwd); + } + }); }) .catch((error: unknown) => { reportError( @@ -8348,7 +8618,8 @@ export function App({ openGoals, createNewSession, ensureSessionForPrompt, - scheduleDelayedSessionListReload, + getComposerWorkspaceCwd, + sessionCatalogController, gitDiffWorkspaceCwd, sessionWorktree, gitHubPrsSupported, @@ -8360,13 +8631,13 @@ export function App({ blockLocalCommandDuringTurn, createSideTask, sideTasksAvailable, - getComposerWorkspaceCwd, openEnvironmentTasksPanel, hiddenCommands, pushToast, reportError, runVisibleRecap, runVisibleBtw, + reconcileCatalogRename, requireActiveSessionForLocalCommand, restartSseOnPrompt, resumeChatBottomFollow, @@ -9819,9 +10090,9 @@ export function App({ setMainView('chat'); closePanel(); }} + onSessionRenameConfirmed={reconcileCatalogRename} onError={reportError} mobileOpen={mobileDrawerOpen} - sessionListReloadToken={sessionListReloadToken} selectedWorkspaceCwd={selectedWorkspaceCwd} onSelectWorkspace={setSelectedWorkspaceCwd} onOpenGitDiff={(workspaceCwd) => @@ -10414,9 +10685,6 @@ export function App({ // callback stable to avoid looping SplitView's reporting // effect. onPanesChange={handleSplitPanesChange} - // Refresh the "add pane" picker when the session list - // changes elsewhere, matching the sidebar. - sessionListReloadToken={sessionListReloadToken} includeOtherWorkspaces={!lockedWorkspaceCwd} workspaceCwd={lockedWorkspaceCwd} // Back returns to the Session Overview (the hub the split diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index ba04f6c5b5c..1625c5bf737 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -18,6 +18,15 @@ import { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +const catalogController = vi.hoisted(() => ({ + invalidateWorkspace: vi.fn(), + sessionCreated: vi.fn(), + promptAdmitted: vi.fn(), + promptAdmissionUncertain: vi.fn(), + renamed: vi.fn(), + turnCompleted: vi.fn(), +})); + /* eslint-disable @typescript-eslint/no-explicit-any */ let connectionState: any; let streamingStateValue: string; @@ -95,10 +104,18 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ }), usePromptStatus: () => 'idle', useWorkspaceActions: () => ({}), - useWorkspace: () => ({ capabilities: connectionState.capabilities }), + useWorkspace: () => ({ + capabilities: connectionState.capabilities, + client: {}, + workspaceCwd: '/primary', + }), useWorkspaceEventSignals: () => ({ artifactsVersion: 0 }), })); +vi.mock('../session-catalog/session-catalog-hooks', () => ({ + useSessionCatalogController: () => catalogController, +})); + vi.mock('../hooks/useQueuedPrompts', () => ({ useQueuedPrompts: () => ({ queuedPrompts: queuedPromptsMock, @@ -315,6 +332,10 @@ beforeEach(() => { editLastQueuedPrompt.mockClear(); clearQueuedPrompts.mockClear(); transcriptDispatch.mockClear(); + catalogController.invalidateWorkspace.mockClear(); + catalogController.promptAdmitted.mockClear(); + catalogController.promptAdmissionUncertain.mockClear(); + catalogController.turnCompleted.mockClear(); }); afterEach(() => { @@ -864,10 +885,49 @@ describe('ChatPane', () => { expect(returned).toBe(false); expect(commit).not.toHaveBeenCalled(); act(() => sendPromptAdmit!()); + expect(catalogController.promptAdmitted).toHaveBeenCalledWith( + '/w', + 'sess-1', + ); expect(commit).toHaveBeenCalledTimes(1); expect(clearFollowup).toHaveBeenCalledTimes(1); }); + it('does not attribute prompt admission across a workspace mismatch', () => { + connectionState.workspaceCwd = '/other'; + render({ workspaceCwd: '/w' }); + + act(() => { + latestOnSubmit!('hi'); + sendPromptAdmit!(); + }); + + expect(catalogController.promptAdmitted).not.toHaveBeenCalled(); + }); + + it('does not update a catalog without an owning workspace', () => { + connectionState.workspaceCwd = undefined; + render(); + + act(() => { + latestOnSubmit!('hi'); + sendPromptAdmit!(); + }); + + expect(catalogController.promptAdmitted).not.toHaveBeenCalled(); + + streamingStateValue = 'responding'; + rerender(); + act(() => { + latestOnSubmit!('queued next'); + }); + expect(catalogController.invalidateWorkspace).not.toHaveBeenCalled(); + + streamingStateValue = 'idle'; + rerender(); + expect(catalogController.turnCompleted).not.toHaveBeenCalled(); + }); + it('forwards images with an idle prompt', () => { const images = [{ data: 'image-data', media_type: 'image/png' }]; render(); @@ -944,6 +1004,7 @@ describe('ChatPane', () => { undefined, expect.any(Function), ); + expect(catalogController.invalidateWorkspace).toHaveBeenCalledWith('/w'); expect(sendPrompt).not.toHaveBeenCalled(); }); @@ -965,6 +1026,67 @@ describe('ChatPane', () => { expect(onFirstPromptAdmitted).toHaveBeenCalledWith('name this queued task'); }); + it('resynchronizes the owning catalog when a pane turn completes', () => { + streamingStateValue = 'responding'; + render(); + + streamingStateValue = 'idle'; + rerender(); + + expect(catalogController.turnCompleted).toHaveBeenCalledWith('/w'); + }); + + it('does not duplicate turn completion owned by the outer session', () => { + streamingStateValue = 'responding'; + render({ reportCatalogTurnCompletion: false }); + + streamingStateValue = 'idle'; + rerender({ reportCatalogTurnCompletion: false }); + + expect(catalogController.turnCompleted).not.toHaveBeenCalled(); + }); + + it('does not attribute a completed pane turn to a different workspace', () => { + streamingStateValue = 'responding'; + render(); + + connectionState.workspaceCwd = '/other'; + streamingStateValue = 'idle'; + rerender(); + + expect(catalogController.turnCompleted).not.toHaveBeenCalled(); + }); + + it('captures a pane identity that becomes available mid-turn', () => { + connectionState.sessionId = undefined; + streamingStateValue = 'responding'; + render(); + + connectionState.sessionId = 'sess-late'; + rerender(); + streamingStateValue = 'idle'; + rerender(); + + expect(catalogController.turnCompleted).toHaveBeenCalledWith('/w'); + }); + + it('captures a pane workspace that becomes available mid-turn', () => { + connectionState.workspaceCwd = undefined; + streamingStateValue = 'responding'; + render(); + + connectionState.workspaceCwd = '/secondary'; + rerender(); + streamingStateValue = 'idle'; + rerender(); + + expect(catalogController.turnCompleted).toHaveBeenCalledTimes(1); + expect(catalogController.turnCompleted).toHaveBeenCalledWith('/secondary'); + expect(catalogController.turnCompleted).not.toHaveBeenCalledWith( + '/primary', + ); + }); + it('forwards composer annotations with a queued prompt', () => { streamingStateValue = 'responding'; const inputAnnotations = [ @@ -1084,6 +1206,10 @@ describe('ChatPane', () => { const notice = testid('pane-prompt-admission-unknown'); expect(notice).not.toBeNull(); expect(latestChatEditorProps.disabled).toBe(true); + expect(catalogController.promptAdmissionUncertain).toHaveBeenCalledWith( + '/w', + ); + expect(catalogController.promptAdmitted).not.toHaveBeenCalled(); act(() => latestOnSubmit!('do not retry')); expect(sendPrompt).toHaveBeenCalledTimes(1); diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index 521d9945516..fbfdcb6c686 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -76,6 +76,7 @@ import { skillDescriptionKey, } from '../constants/localCommands'; import { mergeCommands } from '../hooks/daemonSessionMappers'; +import { useSessionCatalogController } from '../session-catalog/session-catalog-hooks'; import { MessageList } from './MessageList'; import { StreamingStatus } from './StreamingStatus'; import { ChatEditor, type ComposerToolbarAction } from './ChatEditor'; @@ -194,6 +195,8 @@ export interface ChatPaneProps { /** Render inside a parent surface that already provides its own frame. */ embedded?: boolean; onFirstPromptAdmitted?: (text: string) => void; + /** Whether this pane owns Session Catalog turn-completion reconciliation. */ + reportCatalogTurnCompletion?: boolean; hidden?: boolean; voiceUserRevision?: number; voiceWorkspaceRevisions?: Readonly>; @@ -226,6 +229,7 @@ export function ChatPane({ restartSseOnPrompt = false, embedded = false, onFirstPromptAdmitted, + reportCatalogTurnCompletion = true, hidden = false, voiceUserRevision = 0, voiceWorkspaceRevisions = EMPTY_VOICE_WORKSPACE_REVISIONS, @@ -238,6 +242,9 @@ export function ChatPane({ const connection = useConnection(); const actions = useActions(); const workspace = useWorkspace(); + const sessionCatalogController = useSessionCatalogController( + workspace.client, + ); const blocks = useAnimationFrameTranscriptBlocks(); const messages = useMessagesFromBlocks(t, blocks); const transcriptHistory = useTranscriptHistory(); @@ -314,6 +321,54 @@ export function ChatPane({ }, [artifacts, connection.sessionId, onPaneArtifactsChange]); const streamingStateRef = useRef(streamingState); streamingStateRef.current = streamingState; + const catalogOwnerCwd = + connection.workspaceCwd && + workspaceCwd && + connection.workspaceCwd !== workspaceCwd + ? undefined + : (connection.workspaceCwd ?? workspaceCwd); + const previousCatalogStreamingStateRef = useRef(streamingState); + const catalogStreamingSessionIdRef = useRef( + streamingState !== 'idle' ? connection.sessionId : undefined, + ); + const catalogStreamingWorkspaceCwdRef = useRef( + streamingState !== 'idle' ? catalogOwnerCwd : undefined, + ); + useEffect(() => { + const previous = previousCatalogStreamingStateRef.current; + previousCatalogStreamingStateRef.current = streamingState; + if ( + streamingState !== 'idle' && + (previous === 'idle' || + catalogStreamingSessionIdRef.current === undefined) + ) { + catalogStreamingSessionIdRef.current = connection.sessionId; + catalogStreamingWorkspaceCwdRef.current = catalogOwnerCwd; + } else if ( + streamingState !== 'idle' && + connection.sessionId === catalogStreamingSessionIdRef.current && + catalogStreamingWorkspaceCwdRef.current === undefined + ) { + catalogStreamingWorkspaceCwdRef.current = catalogOwnerCwd; + } + if ( + previous !== 'idle' && + streamingState === 'idle' && + connection.sessionId && + connection.sessionId === catalogStreamingSessionIdRef.current && + catalogOwnerCwd && + catalogOwnerCwd === catalogStreamingWorkspaceCwdRef.current && + reportCatalogTurnCompletion + ) { + sessionCatalogController.turnCompleted(catalogOwnerCwd); + } + }, [ + catalogOwnerCwd, + connection.sessionId, + reportCatalogTurnCompletion, + sessionCatalogController, + streamingState, + ]); const firstPromptAdmittedRef = useRef(false); const [unknownPromptAdmission, setUnknownPromptAdmission] = useState(null); @@ -550,6 +605,12 @@ export function ChatPane({ }, onAdmitted: () => { if (admissionOwnerRef.current !== admissionOwner) return; + if (connection.sessionId && catalogOwnerCwd) { + sessionCatalogController.promptAdmitted( + catalogOwnerCwd, + connection.sessionId, + ); + } admitted = true; notifyFirstPromptAdmitted(); clearFollowup(); @@ -564,6 +625,11 @@ export function ChatPane({ reportError(error, 'Failed to send prompt'); return; } + if (catalogOwnerCwd) { + sessionCatalogController.promptAdmissionUncertain( + catalogOwnerCwd, + ); + } setUnknownPromptAdmission({ owner: admissionOwner, commitAccepted, @@ -577,20 +643,25 @@ export function ChatPane({ }); return false; } - if (!trimmed && !inputAnnotations) { - return enqueuePrompt(trimmed, images); + const queued = + !trimmed && !inputAnnotations + ? enqueuePrompt(trimmed, images) + : enqueuePrompt( + trimmed, + images, + undefined, + inputAnnotations, + notifyFirstPromptAdmitted, + ); + if (queued !== false && catalogOwnerCwd) { + sessionCatalogController.invalidateWorkspace(catalogOwnerCwd); } - return enqueuePrompt( - trimmed, - images, - undefined, - inputAnnotations, - notifyFirstPromptAdmitted, - ); + return queued; }, [ actions, admissionPayloadLocked, + catalogOwnerCwd, clearFollowup, connection.sessionId, connection.status, @@ -599,6 +670,7 @@ export function ChatPane({ onImageIngestionNotice, reportError, restartSseOnPrompt, + sessionCatalogController, t, ], ); diff --git a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx index 1ad4c040643..40f4c28e015 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx @@ -6,6 +6,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as React from 'react'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import type { @@ -34,7 +35,7 @@ let statusState: { let otherWorkspaceSessions: Record; // Stable client object (per test) so the other-workspace hook's load callback // keeps a stable identity and its effect doesn't loop. -let workspaceClient: { listWorkspaceSessions: ReturnType }; +let workspaceClient: { listWorkspaceSessionsPage: ReturnType }; const sessionsReload = vi.fn(async () => sessionsState.sessions); const statusReload = vi.fn(async () => statusState.report); @@ -46,9 +47,31 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ useWorkspace: () => ({ client: workspaceClient, capabilities: connectionState.capabilities, + workspaceCwd: connectionState.workspaceCwd, }), })); +vi.mock('../hooks/useScopedSessions', () => ({ + useScopedSessions: ( + _workspaceCwd: string | undefined, + options: { pollIntervalMs?: number } = {}, + ) => { + const inFlight = React.useRef(false); + React.useEffect(() => { + if (options.pollIntervalMs === undefined) return; + const timer = setInterval(() => { + if (document.hidden || inFlight.current) return; + inFlight.current = true; + void sessionsReload().finally(() => { + inFlight.current = false; + }); + }, options.pollIntervalMs); + return () => clearInterval(timer); + }, [options.pollIntervalMs]); + return { ...sessionsState, reload: sessionsReload }; + }, +})); + const { SessionOverviewPanel, deriveSessionCards } = await import( './SessionOverviewPanel' ); @@ -100,9 +123,9 @@ beforeEach(() => { statusState = { report: { full: { sessions: [] } } }; otherWorkspaceSessions = {}; workspaceClient = { - listWorkspaceSessions: vi.fn( - async (cwd: string) => otherWorkspaceSessions[cwd] ?? [], - ), + listWorkspaceSessionsPage: vi.fn(async (cwd: string) => ({ + sessions: otherWorkspaceSessions[cwd] ?? [], + })), }; sessionsReload.mockClear(); statusReload.mockClear(); @@ -488,7 +511,7 @@ describe('SessionOverviewPanel', () => { sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })]; render(); await flushAsync(); - expect(workspaceClient.listWorkspaceSessions).not.toHaveBeenCalled(); + expect(workspaceClient.listWorkspaceSessionsPage).not.toHaveBeenCalled(); // No workspace badge on a single-workspace daemon. expect(container!.textContent).not.toContain('wsB'); }); @@ -578,9 +601,9 @@ describe('SessionOverviewPanel polling', () => { try { render(); await vi.advanceTimersByTimeAsync(10); // settle the initial fan-out - workspaceClient.listWorkspaceSessions.mockClear(); + workspaceClient.listWorkspaceSessionsPage.mockClear(); await vi.advanceTimersByTimeAsync(3100); // one list-poll tick - expect(workspaceClient.listWorkspaceSessions).toHaveBeenCalledWith( + expect(workspaceClient.listWorkspaceSessionsPage).toHaveBeenCalledWith( '/wsB', expect.objectContaining({ archiveState: 'active' }), ); diff --git a/packages/web-shell/client/components/SessionOverviewPanel.tsx b/packages/web-shell/client/components/SessionOverviewPanel.tsx index a0bad7070f8..34315e632f3 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.tsx @@ -176,6 +176,7 @@ function SessionOverviewPanelInner({ const { sessions, loading, error, reload } = useScopedSessions(workspaceCwd, { autoLoad: true, + pollIntervalMs: LIST_POLL_MS, pageSize: SESSION_LIST_PAGE_SIZE, archiveState: 'active', ...(organizationEnabled @@ -186,7 +187,10 @@ function SessionOverviewPanelInner({ // single-workspace daemon), so the overview is mission control for every // workspace, not just the primary one. const { sessions: otherSessions, reload: reloadOther } = - useOtherWorkspaceSessions(includeOtherWorkspaces && !workspaceCwd); + useOtherWorkspaceSessions( + includeOtherWorkspaces && !workspaceCwd, + LIST_POLL_MS, + ); const mergedSessions = useMemo( () => mergeSessionsById(sessions, otherSessions), [sessions, otherSessions], @@ -202,20 +206,6 @@ function SessionOverviewPanelInner({ const [selected, setSelected] = useState>(() => new Set()); const [popupBlocked, setPopupBlocked] = useState(false); - // Poll the cheap list. Skip a tick when the tab is hidden or the previous - // request is still outstanding (mirrors the sidebar / daemon-status polls). - const listInFlight = useRef(false); - useEffect(() => { - const timer = window.setInterval(() => { - if (document.hidden || listInFlight.current) return; - listInFlight.current = true; - void Promise.all([reload(), reloadOther()]).finally(() => { - listInFlight.current = false; - }); - }, LIST_POLL_MS); - return () => window.clearInterval(timer); - }, [reload, reloadOther]); - // Poll the richer status report less often — it is the only source of // per-session "needs approval" and current-model, but costs more to build. const statusInFlight = useRef(false); @@ -312,9 +302,9 @@ function SessionOverviewPanelInner({ }, [splitIds]); const refresh = useCallback(() => { - void reload(); - void reloadOther(); - void statusReload(); + void reload().catch(() => undefined); + void reloadOther().catch(() => undefined); + void statusReload().catch(() => undefined); }, [reload, reloadOther, statusReload]); if (cards.length === 0) { diff --git a/packages/web-shell/client/components/SplitView.test.tsx b/packages/web-shell/client/components/SplitView.test.tsx index 8f1c5201b58..f76714e00e7 100644 --- a/packages/web-shell/client/components/SplitView.test.tsx +++ b/packages/web-shell/client/components/SplitView.test.tsx @@ -22,7 +22,7 @@ let otherWorkspaceSessions: Record; // Stable client object (assigned once per test) so the other-workspace hook's // load callback keeps a stable identity and its effect doesn't loop. let workspaceClient: { - listWorkspaceSessions: ReturnType; + listWorkspaceSessionsPage: ReturnType; workspaceByCwd: ReturnType; }; // Stable across renders (assigned once per test) so SplitView's reload effects, @@ -62,6 +62,29 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ }, })); +vi.mock('../hooks/useScopedSessions', () => ({ + useScopedSessions: (workspaceCwd: string | undefined) => { + const [sessions, setSessions] = React.useState(() => + workspaceCwd ? [] : sessionsState, + ); + React.useEffect(() => { + if (!workspaceCwd) return; + void Promise.resolve().then(() => { + setSessions([...(otherWorkspaceSessions[workspaceCwd] ?? [])]); + }); + }, [workspaceCwd]); + const reload = React.useCallback(async () => { + reloadMock(); + const next = workspaceCwd + ? (otherWorkspaceSessions[workspaceCwd] ?? []) + : sessionsState; + setSessions([...next]); + return next; + }, [workspaceCwd]); + return { sessions, reload }; + }, +})); + vi.mock('./ChatPane', () => ({ ChatPane: (props: any) => { // Let a test force a render crash to exercise the per-pane ErrorBoundary. @@ -74,6 +97,9 @@ vi.mock('./ChatPane', () => ({ data-pane-restart-sse={props.restartSseOnPrompt ? 'true' : 'false'} data-slash-handler={props.onSlashCommand ? 'true' : 'false'} data-hidden={props.hidden ? 'true' : 'false'} + data-report-catalog-turn-completion={ + props.reportCatalogTurnCompletion ? 'true' : 'false' + } data-voice-user-revision={String(props.voiceUserRevision ?? 0)} data-voice-workspace-count={String(props.voiceWorkspaces?.length ?? 0)} > @@ -120,13 +146,13 @@ beforeEach(() => { ]; otherWorkspaceSessions = {}; workspaceClient = { - listWorkspaceSessions: vi.fn( - async (cwd: string) => otherWorkspaceSessions[cwd] ?? [], - ), + listWorkspaceSessionsPage: vi.fn(async (cwd: string) => ({ + sessions: otherWorkspaceSessions[cwd] ?? [], + })), workspaceByCwd: vi.fn((cwd: string) => ({ - listWorkspaceSessions: vi.fn( - async () => otherWorkspaceSessions[cwd] ?? [], - ), + listWorkspaceSessionsPage: vi.fn(async () => ({ + sessions: otherWorkspaceSessions[cwd] ?? [], + })), })), }; reloadMock = vi.fn(); @@ -201,6 +227,30 @@ describe('SplitView', () => { expect(s2ClientId).toBe(`split-pane:${nonce}:s2`); }); + it('leaves the outer session as the sole catalog turn-completion owner', () => { + render({ sessionIds: ['s3', 's1'] }); + + expect( + panes()[0]?.getAttribute('data-report-catalog-turn-completion'), + ).toBe('false'); + expect( + panes()[1]?.getAttribute('data-report-catalog-turn-completion'), + ).toBe('true'); + }); + + it('reports completion for the same session id in another workspace', async () => { + otherWorkspaceSessions['/wsB'] = [ + { sessionId: 's3', workspaceCwd: '/wsB', displayName: 'Other Three' }, + ]; + + render({ sessionIds: ['s3'], workspaceCwd: '/wsB' }); + await flushAsync(); + + expect( + panes()[0]?.getAttribute('data-report-catalog-turn-completion'), + ).toBe('true'); + }); + it('passes the prompt SSE restart option to pane providers', () => { render({ sessionIds: ['s1'], restartSseOnPrompt: true }); expect( @@ -735,24 +785,6 @@ describe('SplitView', () => { expect(pickerOptions()).toEqual(['Two', 'Three', 'Four', 'Five']); }); - it('reloads the picker list when the parent bumps the reload token', () => { - render({ sessionIds: ['s1'], sessionListReloadToken: 0 }); - // The initial token is not a change, so it does not trigger a reload. - expect(reloadMock).not.toHaveBeenCalled(); - act(() => - root!.render( - - {}} - sessionIds={['s1']} - sessionListReloadToken={1} - /> - , - ), - ); - expect(reloadMock).toHaveBeenCalledTimes(1); - }); - it('mirrors the live pane set up to the parent as panes change', () => { const onPanesChange = vi.fn(); render({ onPanesChange }); @@ -903,7 +935,7 @@ describe('SplitView', () => { // must not touch the daemon and the picker stays untagged. render({ sessionIds: ['s1'] }); await flushAsync(); - expect(workspaceClient.listWorkspaceSessions).not.toHaveBeenCalled(); + expect(workspaceClient.listWorkspaceSessionsPage).not.toHaveBeenCalled(); openPicker(); expect(pickerOptions()).toEqual(['Two', 'Three', 'Four']); }); @@ -924,13 +956,13 @@ describe('SplitView', () => { ]; render({ sessionIds: ['s1'] }); await flushAsync(); - const before = workspaceClient.listWorkspaceSessions.mock.calls.length; + const before = workspaceClient.listWorkspaceSessionsPage.mock.calls.length; openPicker(); await flushAsync(); // Opening the picker reloads the other-workspace list so it never offers a // stale set (mirrors the primary `reload()` on picker open). expect( - workspaceClient.listWorkspaceSessions.mock.calls.length, + workspaceClient.listWorkspaceSessionsPage.mock.calls.length, ).toBeGreaterThan(before); }); diff --git a/packages/web-shell/client/components/SplitView.tsx b/packages/web-shell/client/components/SplitView.tsx index 3ca9f611502..9f8171f44a5 100644 --- a/packages/web-shell/client/components/SplitView.tsx +++ b/packages/web-shell/client/components/SplitView.tsx @@ -74,12 +74,7 @@ export interface SplitViewProps { * button. See `ChatPaneProps.renderHeaderActions`. */ renderPaneHeaderActions?: PaneHeaderActionsRenderer; - /** - * Bumped by the parent whenever the session list changes elsewhere (create / - * delete / rename). The "add pane" picker reloads on a change so it never - * offers a session that has since been removed or misses one just created. - */ - sessionListReloadToken?: number; + /** Include active sessions from every trusted registered workspace. */ includeOtherWorkspaces?: boolean; /** Limit session discovery and pane attachment to this workspace. */ workspaceCwd?: string; @@ -112,7 +107,6 @@ export function SplitView({ onPaneArtifactsChange, messageTurnOutputs, renderPaneHeaderActions, - sessionListReloadToken, includeOtherWorkspaces = true, workspaceCwd, restartSseOnPrompt, @@ -214,35 +208,11 @@ export function SplitView({ // created since the split was first entered. useEffect(() => { if (pickerOpen) { - void reload(); - void reloadOther(); + void reload().catch(() => undefined); + void reloadOther().catch(() => undefined); } }, [pickerOpen, reload, reloadOther]); - // Also refresh when the parent signals the list changed elsewhere (a session - // created / deleted / renamed in the sidebar or another tab), so an open - // picker — or the next open — reflects it without re-entering the split. - // Reload on every distinct token bump. `useDaemonResource` serializes - // responses via its sequence counter (last write wins), so overlapping reloads - // are safe; and the token is bumped only on discrete session-change events - // (App fires an immediate bump plus one delayed follow-up per change), not as a - // high-frequency stream. Deliberately *not* skipping while a reload is in - // flight: doing so would drop a bump that lands mid-reload — the effect has - // already run for that value and clearing an in-flight flag wouldn't re-run it, - // so the picker could stay stale after a burst. An occasional redundant fetch - // is far cheaper than a lost refresh, and the split has no polling fallback. - const prevReloadTokenRef = useRef(sessionListReloadToken); - useEffect(() => { - if ( - sessionListReloadToken !== undefined && - sessionListReloadToken !== prevReloadTokenRef.current - ) { - prevReloadTokenRef.current = sessionListReloadToken; - void reload(); - void reloadOther(); - } - }, [sessionListReloadToken, reload, reloadOther]); - const titleById = useMemo(() => { const map = new Map(); for (const session of allSessions) { @@ -515,6 +485,10 @@ export function SplitView({