diff --git a/docs/developers/daemon-ui/sidebar-customization.md b/docs/developers/daemon-ui/sidebar-customization.md index 6efc223dda9..4bd1c0fd2c2 100644 --- a/docs/developers/daemon-ui/sidebar-customization.md +++ b/docs/developers/daemon-ui/sidebar-customization.md @@ -122,7 +122,7 @@ type WebShellSidebarFooterItem = | 'settings' // ⚙ Settings panel | 'version' // version label (e.g. "v0.19.10") | 'theme' // ☀/🌙 light/dark toggle - | 'sessionsOverview' // ▦ session overview panel (large screens only) + | 'sessionsOverview' // ▦ session overview panel | 'splitView' // ◧ split view (large screens only) | 'daemonStatus' // 📊 daemon status panel | 'collapse'; // ◁/▷ collapse/expand toggle @@ -228,7 +228,6 @@ type WebShellSidebarSessionActionItem = /** Subset with working inline (hover-button) handlers. */ type WebShellSidebarSessionInlineActionItem = | 'pin' - | 'archive' | 'rename' | 'export' | 'delete'; @@ -242,22 +241,22 @@ interface WebShellSidebarSessionActionsOptions { Controls which action buttons appear on session rows: - **`items`**: Master control for all actions (both inline and dropdown). If an item is not in `items`, it's hidden everywhere. -- **`inlineItems`**: Controls which items appear as **inline buttons** (on hover). Defaults to `['pin']` — archive stays in the dropdown by default because the hover slot sits on the row's click target and is easy to hit by accident. Only items with working inline handlers can be used: `'pin'`, `'archive'`, `'rename'`, `'export'`, `'delete'`. `'details'` and `'group'` are dropdown-only. +- **`inlineItems`**: Controls which items appear as **inline buttons** (on hover). Defaults to `['pin']`. Only items with working inline handlers can be used: `'pin'`, `'rename'`, `'export'`, `'delete'`. `'details'`, `'group'`, and `'archive'` are dropdown-only. **Visibility priority**: Both `items` AND the item's built-in condition AND `inlineItems` must all pass for the inline button to show. For example, `delete` as inline requires `items` to include `'delete'` AND `inlineItems` to include `'delete'`. -| Value | Effect | -| ---------------------------------------- | ------------------------------------- | -| `undefined` (default) | All actions shown, only pin as inline | -| `{ inlineItems: ['pin', 'delete'] }` | Pin + delete as inline buttons | -| `{ inlineItems: [] }` | No inline buttons at all | -| `{ inlineItems: ['archive', 'export'] }` | Archive + export as inline buttons | +| Value | Effect | +| --------------------------------------- | --------------------------------- | +| `undefined` (default) | All actions shown, pin as inline | +| `{ inlineItems: ['pin', 'delete'] }` | Pin + delete as inline buttons | +| `{ inlineItems: [] }` | No inline buttons at all | +| `{ inlineItems: ['rename', 'export'] }` | Rename + export as inline buttons | The dropdown trigger (⋮) is automatically hidden when no dropdown items -are enabled. Inline buttons are only shown when both their capability -condition and `items` include them. Archive is disabled on the current -session and on any session with a running turn, because the daemon closes -the live session when it archives. +are enabled. Inline buttons are only shown when both +their capability condition and `items` include them. Archive is disabled on +the current session and on any session with a running turn, because the daemon +closes the live session when it archives. ```tsx sidebar={{ diff --git a/package-lock.json b/package-lock.json index 00dc9a9fe89..f90779d0c2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8568,6 +8568,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", @@ -8585,6 +8605,19 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/virtual-core": { "version": "3.17.0", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", @@ -33362,6 +33395,7 @@ "@datafe-open/markdown-chart-echarts": "^0.1.12", "@datafe-open/markdown-chart-react": "^0.1.12", "@modelcontextprotocol/ext-apps": "^1.7.5", + "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.26", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt index 4b718c3263c..7016125c20e 100644 --- a/packages/vscode-ide-companion/NOTICES.txt +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -19861,6 +19861,60 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +============================================================ +@tanstack/react-table@8.21.3 +(https://github.com/TanStack/table.git) + +MIT License + +Copyright (c) 2016 Tanner Linsley + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +@tanstack/table-core@8.21.3 +(https://github.com/TanStack/table.git) + +MIT License + +Copyright (c) 2016 Tanner Linsley + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + ============================================================ @tanstack/react-virtual@3.14.2 (git+https://github.com/TanStack/virtual.git) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 508fa51b2cd..aa6d792286e 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -438,6 +438,15 @@ const { isPending: boolean; } | null, latestAddWorkspaceDialogProps: null as AddWorkspaceDialogTestProps | null, + latestSessionOverviewProps: null as { + onOpenSession?: (sessionId: string, workspaceCwd?: string) => void; + onOpenSplit?: (sessionIds: string[]) => void; + onCurrentSessionRemoved?: (session: { + sessionId: string; + workspaceCwd: string; + }) => Promise | boolean | void; + manageLiveState?: boolean; + } | null, latestToolApprovalKeyboardActive: null as boolean | null, toolApprovalKeyboardActiveHistory: [] as Array, latestToolApprovalPlanTodos: [] as Array<{ id: string }>, @@ -1058,6 +1067,7 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => { onMobileClose?: () => void; onNewSession?: () => Promise | boolean; onLoadSession?: (sessionId: string) => Promise | void; + onSelectCurrentSession?: () => void; onSessionsDeleted?: (sessionIds: string[]) => void; onOpenAddWorkspace?: () => void; showSessionSourceSwitch?: boolean; @@ -1100,6 +1110,15 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => { }, 'load session', ), + React.createElement( + 'button', + { + 'data-testid': 'select-current-session', + type: 'button', + onClick: props.onSelectCurrentSession, + }, + 'select current session', + ), React.createElement( 'button', { @@ -1319,7 +1338,25 @@ mockComponent('./components/dialogs/ToolsDialog', 'ToolsDialog'); mockComponent('./components/tools/ToolsManagerPage', 'ToolsManagerPage'); mockComponent('./components/skills/SkillsManagerPage', 'SkillsManagerPage'); mockComponent('./components/dialogs/DaemonStatusDialog', 'DaemonStatusDialog'); -mockComponent('./components/SessionOverviewPanel', 'SessionOverviewPanel'); +vi.doMock('./components/SessionOverviewPanel', async () => { + const React = await import('react'); + return { + SessionOverviewPanel: (props: { + onOpenSession?: (sessionId: string, workspaceCwd?: string) => void; + onOpenSplit?: (sessionIds: string[]) => void; + onCurrentSessionRemoved?: (session: { + sessionId: string; + workspaceCwd: string; + }) => Promise | boolean | void; + manageLiveState?: boolean; + }) => { + testState.latestSessionOverviewProps = props; + return React.createElement('div', { + 'data-testid': 'session-overview-panel', + }); + }, + }; +}); vi.doMock('./components/channels/ChannelsManagerPage', async () => { const React = await import('react'); return { @@ -5229,6 +5266,7 @@ beforeEach(() => { testState.latestMessageListProps = null; testState.latestBtwMessageProps = null; testState.latestAddWorkspaceDialogProps = null; + testState.latestSessionOverviewProps = null; testState.latestToolApprovalKeyboardActive = null; testState.toolApprovalKeyboardActiveHistory = []; testState.latestToolApprovalPlanTodos = []; @@ -12733,7 +12771,7 @@ describe('App session callbacks', () => { it('restores the Goal snapshot when the same session learns its workspace', async () => { const snapshots: unknown[] = []; - mockConnection.workspaceCwd = undefined; + mockConnection.workspaceCwd = ''; mockConnection.goalState = activeGoalSnapshot(); const { rerender } = renderApp({ renderFooter: (props) => { @@ -18224,500 +18262,839 @@ describe('App session callbacks', () => { expect(panel?.getAttribute('aria-label')).toBe('Session Overview'); }); - it('forwards the session-source switch customization to the sidebar', async () => { - const { container, rerender } = renderApp({ - sidebar: { enabled: true, showSessionSourceSwitch: false }, - }); + it('clears the current session without closing the Session Overview', async () => { + const onSessionIdChange = vi.fn(); + const { container } = renderApp({ onSessionIdChange }); await flush(); - expect( + await act(async () => { container - .querySelector('[data-testid="sidebar"]') - ?.getAttribute('data-show-session-source-switch'), - ).toBe('false'); - - rerender({ sidebar: { enabled: true } }); - await flush(); + .querySelector( + '[data-testid="open-sessions-overview"]', + ) + ?.click(); + await Promise.resolve(); + }); + await act(async () => { + await testState.latestSessionOverviewProps?.onCurrentSessionRemoved?.({ + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + }); + }); + expect(mockSessionActions.clearSession).toHaveBeenCalledOnce(); + expect(onSessionIdChange).toHaveBeenCalledWith(undefined); expect( - container - .querySelector('[data-testid="sidebar"]') - ?.getAttribute('data-show-session-source-switch'), - ).toBe('true'); - - rerender({ sidebar: true }); - await flush(); - + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); expect( - container - .querySelector('[data-testid="sidebar"]') - ?.getAttribute('data-show-session-source-switch'), - ).toBe('true'); + container.querySelector('[data-testid="session-overview-panel"]'), + ).not.toBeNull(); + expect(testState.latestSessionOverviewProps?.manageLiveState).toBe(false); }); - it('opens the split view from the sidebar', async () => { - const { container } = renderApp(); + it('clears a legacy current session without a connection workspace cwd', async () => { + mockConnection.workspaceCwd = ''; + mockWorkspace.capabilities = { + workspaces: [{ id: 'primary', cwd: '/tmp/project', primary: true }], + }; + const onSessionIdChange = vi.fn(); + const { container } = renderApp({ onSessionIdChange }); await flush(); await act(async () => { container - .querySelector('[data-testid="open-split-view"]') + .querySelector( + '[data-testid="open-sessions-overview"]', + ) ?.click(); await Promise.resolve(); }); - expect( - container.querySelector('[data-testid="split-view-page"]'), - ).not.toBeNull(); - // The outer chat subtree is hidden (display:none + aria-hidden) behind the - // split, so keyboard/AT can't reach the outer composer/toolbar. Assert the - // node is present first, so a missing subtree fails rather than passing - // vacuously through the optional chain. - const messages = container.querySelector('[data-testid="messages"]'); - expect(messages).not.toBeNull(); - expect(messages?.closest('[aria-hidden="true"]')).not.toBeNull(); + await act(async () => { + await testState.latestSessionOverviewProps?.onCurrentSessionRemoved?.({ + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + }); + }); + + expect(mockSessionActions.clearSession).toHaveBeenCalledOnce(); + expect(onSessionIdChange).toHaveBeenCalledWith(undefined); }); - it('preserves the legacy split Voice workspace fallback', async () => { + it("keeps the deleted current session's workspace for the next chat", async () => { + mockConnection.workspaceCwd = '/work/secondary'; mockWorkspace.capabilities = { - features: ['voice_transcribe'], - workspaceCwd: '/workspace', + workspaces: [ + { id: 'primary', cwd: '/tmp/project', primary: true, trusted: true }, + { + id: 'secondary', + cwd: '/work/secondary', + primary: false, + trusted: true, + }, + ], } as typeof mockWorkspace.capabilities; - saveSplitSessions(['s1']); - - const { container } = renderApp(); + const onSessionIdChange = vi.fn(); + const { container, rerender } = renderApp({ onSessionIdChange }); await flush(); - expect( - container.querySelector('[data-testid="split-voice-workspaces"]') - ?.textContent, - ).toBe('legacy'); - }); + await act(async () => { + container + .querySelector( + '[data-testid="open-sessions-overview"]', + ) + ?.click(); + await Promise.resolve(); + }); + await act(async () => { + await testState.latestSessionOverviewProps?.onCurrentSessionRemoved?.({ + sessionId: 'session-1', + workspaceCwd: '/work/secondary', + }); + }); - it('restores a persisted split on load (survives a refresh)', async () => { - // Simulate the storage left behind by a split that was open before a refresh. - saveSplitSessions(['s1', 's2']); - const { container } = renderApp(); - await flush(); + expect(mockSessionActions.clearSession).toHaveBeenCalledOnce(); + expect(onSessionIdChange).toHaveBeenCalledWith(undefined); expect( - container.querySelector('[data-testid="split-view-page"]'), + container.querySelector('[data-testid="session-overview-panel"]'), ).not.toBeNull(); - expect( - container.querySelector('[data-testid="split-initial"]')?.textContent, - ).toBe('s1,s2'); - }); - - it('does not open the split when nothing was persisted', async () => { - const { container } = renderApp(); + // The daemon cleared the session; the next chat's workspace target now + // comes from the shell's selected-workspace state, which must stay in + // the removed session's workspace instead of bouncing to the primary. + mockConnection.sessionId = undefined; + rerender(); await flush(); - expect( - container.querySelector('[data-testid="split-view-page"]'), - ).toBeNull(); + expect(testState.latestChatEditorProps?.selectedWorkspaceCwd).toBe( + '/work/secondary', + ); }); - it('clears the persisted split when the user leaves the split view', async () => { - saveSplitSessions(['s1', 's2']); - const { container } = renderApp(); + it('does not clear a different session after an overview mutation finishes', async () => { + const onSessionIdChange = vi.fn(); + const { container, rerender } = renderApp({ onSessionIdChange }); await flush(); - // Restored into the split; leaving via its back button must clear storage - // so a later refresh doesn't bring the split back uninvited. - expect( - container.querySelector('[data-testid="split-view-page"]'), - ).not.toBeNull(); + await act(async () => { container - .querySelector('[data-testid="split-back"]') + .querySelector( + '[data-testid="open-sessions-overview"]', + ) ?.click(); await Promise.resolve(); }); - expect(loadSplitSessions()).toEqual([]); - }); + mockConnection.workspaceCwd = '/other/project'; + rerender(); + await flush(); + onSessionIdChange.mockClear(); - it('syncs the split view from external session ids without the sidebar', async () => { - const { container, rerender } = renderApp({ - sidebar: false, - splitSessionIds: ['s1'], - renderPaneHeaderActions: () => null, + await act(async () => { + await testState.latestSessionOverviewProps?.onCurrentSessionRemoved?.({ + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + }); }); - await flush(); - expect(container.querySelector('[data-testid="sidebar"]')).toBeNull(); - expect( - container.querySelector('[data-testid="split-view-page"]'), - ).not.toBeNull(); - expect( - container.querySelector('[data-testid="split-initial"]')?.textContent, - ).toBe('s1'); - expect( - container.querySelector('[data-testid="split-has-header-actions"]') - ?.textContent, - ).toBe('yes'); + expect(mockSessionActions.clearSession).not.toHaveBeenCalled(); + expect(onSessionIdChange).not.toHaveBeenCalled(); + }); - rerender({ sidebar: false, splitSessionIds: ['s1', 's2'] }); + it('does not clear a session opened while the current session is being cleared', async () => { + let resolveClear!: () => void; + mockSessionActions.clearSession.mockReturnValueOnce( + new Promise((resolve) => { + resolveClear = resolve; + }), + ); + const onSessionIdChange = vi.fn(); + const { container, rerender } = renderApp({ onSessionIdChange }); await flush(); - expect( - container.querySelector('[data-testid="split-initial"]')?.textContent, - ).toBe('s1,s2'); - rerender({ sidebar: false, splitSessionIds: [] }); - await flush(); - expect( - container.querySelector('[data-testid="split-view-page"]'), - ).toBeNull(); + await act(async () => { + container + .querySelector( + '[data-testid="open-sessions-overview"]', + ) + ?.click(); + await Promise.resolve(); + }); + let removal: Promise | boolean | void = undefined; + act(() => { + removal = testState.latestSessionOverviewProps?.onCurrentSessionRemoved?.( + { + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + }, + ); + }); + expect(mockSessionActions.clearSession).toHaveBeenCalledOnce(); - rerender({ sidebar: false, splitSessionIds: ['s1', 's2'] }); + await act(async () => { + container + .querySelector('[data-testid="load-session"]') + ?.click(); + await Promise.resolve(); + }); + mockConnection.sessionId = 'session-2'; + rerender(); await flush(); - expect( - container.querySelector('[data-testid="split-view-page"]'), - ).not.toBeNull(); - expect( - container.querySelector('[data-testid="split-initial"]')?.textContent, - ).toBe('s1,s2'); + onSessionIdChange.mockClear(); + await act(async () => { + resolveClear(); + await removal; + }); + + expect(onSessionIdChange).not.toHaveBeenCalled(); }); - it('dedupes and caps external split session ids', async () => { - const { container } = renderApp({ - sidebar: false, - splitSessionIds: ['s1', 's1', 's2', 's3', 's4', 's5', 's6', 's7'], - }); + it('reports failure when the current session cannot be cleared', async () => { + const onSessionIdChange = vi.fn(); + mockSessionActions.clearSession.mockRejectedValueOnce(new Error('locked')); + const { container } = renderApp({ onSessionIdChange }); await flush(); + await act(async () => { + container + .querySelector( + '[data-testid="open-sessions-overview"]', + ) + ?.click(); + await Promise.resolve(); + }); + onSessionIdChange.mockClear(); + let cleared: boolean | void; + await act(async () => { + cleared = + await testState.latestSessionOverviewProps?.onCurrentSessionRemoved?.({ + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + }); + }); + + expect(cleared).toBe(false); + expect(onSessionIdChange).not.toHaveBeenCalled(); expect( - container.querySelector('[data-testid="split-initial"]')?.textContent, - ).toBe('s1,s2,s3,s4,s5,s6'); + container.querySelector('[data-testid="session-overview-panel"]'), + ).not.toBeNull(); }); - it('opens the token usage panel from the default pane header action', async () => { - const statsFixture: DaemonSessionStatsStatus = { - v: 1, - sessionId: 's1', - workspaceCwd: '/tmp/project', - sessionStartTimeMs: 1000, - durationMs: 60000, - promptCount: 2, - models: { - 'qwen-plus::hybrid': { - api: { totalRequests: 2, totalErrors: 0, totalLatencyMs: 1000 }, - tokens: { - prompt: 500000, - candidates: 100000, - total: 600000, - cached: 50000, - thoughts: 10000, - }, + it('starts a new session when leaving the Session Overview with Back', async () => { + mockConnection.workspaceCwd = '/work/secondary'; + mockWorkspace.capabilities = { + workspaces: [ + { id: 'primary', cwd: '/tmp/project', primary: true, trusted: true }, + { + id: 'secondary', + cwd: '/work/secondary', + primary: false, + trusted: true, }, - }, - tools: { - totalCalls: 3, - totalSuccess: 3, - totalFail: 0, - totalDurationMs: 900, - byName: {}, - }, - files: { totalLinesAdded: 12, totalLinesRemoved: 4 }, - sources: [], - }; - mockSessionActions.getStats.mockResolvedValue(statsFixture); - const { container } = renderApp({ - sidebar: false, - splitSessionIds: ['s1'], - header: { items: ['tokenUsage'] }, - }); + ], + } as typeof mockWorkspace.capabilities; + const onSessionIdChange = vi.fn(); + const { container } = renderApp({ onSessionIdChange }); await flush(); - const entry = container.querySelector( - '[aria-label="Session token usage"]', - ); - expect(entry).not.toBeNull(); await act(async () => { - entry!.click(); + container + .querySelector( + '[data-testid="open-sessions-overview"]', + ) + ?.click(); await Promise.resolve(); }); - await flush(); - - expect(mockSessionActions.getStats).toHaveBeenCalled(); - // The right panel (floating drawer on split view) opened with the token - // usage tab and its live data, portaled into document.body. - expect(document.body.textContent).toContain('Token Usage'); - expect(document.body.textContent).toContain('qwen-plus::hybrid'); - expect(document.body.textContent).toContain('600K'); - await act(async () => { container - .querySelector('[data-testid="split-remove-panes"]')! - .click(); - }); - expect(document.body.textContent).not.toContain('qwen-plus::hybrid'); - }); - - it('does not add a token usage pane action unless it is enabled', async () => { - const { container } = renderApp({ - sidebar: false, - splitSessionIds: ['s1'], + .querySelector('[data-testid="panel-back"]') + ?.click(); + await Promise.resolve(); }); - await flush(); - expect( - container.querySelector('[data-testid="split-has-header-actions"]') - ?.textContent, - ).toBe('no'); + expect(mockSessionActions.clearSession).toHaveBeenCalledOnce(); + expect(onSessionIdChange).toHaveBeenCalledWith(undefined); + expect(testState.latestChatEditorProps?.selectedWorkspaceCwd).toBe( + '/work/secondary', + ); + expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull(); }); - it('does not reopen controlled split view when the same ids get a new array reference', async () => { - const { container, rerender } = renderApp({ - sidebar: false, - splitSessionIds: ['s1', 's2'], - }); + it('leaves the Session Overview when a legacy workspace resolves during Back', async () => { + mockConnection.workspaceCwd = ''; + mockWorkspace.capabilities = { + workspaces: [{ id: 'primary', cwd: '/tmp/project', primary: true }], + }; + let resolveClear!: () => void; + mockSessionActions.clearSession.mockReturnValueOnce( + new Promise((resolve) => { + resolveClear = resolve; + }), + ); + const onSessionIdChange = vi.fn(); + const { container, rerender } = renderApp({ onSessionIdChange }); await flush(); - expect( - container.querySelector('[data-testid="split-view-page"]'), - ).not.toBeNull(); await act(async () => { container - .querySelector('[data-testid="split-back"]') + .querySelector( + '[data-testid="open-sessions-overview"]', + ) ?.click(); await Promise.resolve(); }); - expect( - container.querySelector('[data-testid="split-view-page"]'), - ).toBeNull(); - expect( - container - .querySelector('[data-testid="inline-panel"]') - ?.getAttribute('aria-label'), - ).toBe('Session Overview'); - - rerender({ sidebar: false, splitSessionIds: ['s1', 's2'] }); - await flush(); - expect( - container.querySelector('[data-testid="split-view-page"]'), - ).toBeNull(); - expect( + act(() => { container - .querySelector('[data-testid="inline-panel"]') - ?.getAttribute('aria-label'), - ).toBe('Session Overview'); - }); - - it('notifies external callers when split session ids change inside WebShell', async () => { - const onSplitSessionIdsChange = vi.fn(); - const { container, rerender } = renderApp({ - sidebar: false, - splitSessionIds: ['s1'], - onSplitSessionIdsChange, + .querySelector('[data-testid="panel-back"]') + ?.click(); }); + mockConnection.workspaceCwd = '/tmp/project'; + rerender(); await flush(); - await act(async () => { - container - .querySelector('[data-testid="split-report-panes"]') - ?.click(); + resolveClear(); await Promise.resolve(); }); - expect(onSplitSessionIdsChange).toHaveBeenCalledWith(['s1', 's2', 's3']); - expect( - container.querySelector('[data-testid="split-initial"]')?.textContent, - ).toBe('s1'); - - rerender({ - sidebar: false, - splitSessionIds: ['s1', 's2', 's3'], - onSplitSessionIdsChange, - }); - await flush(); - expect( - container.querySelector('[data-testid="split-initial"]')?.textContent, - ).toBe('s1,s2,s3'); + expect(onSessionIdChange).toHaveBeenCalledWith(undefined); + expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull(); }); - it('notifies external callers when uncontrolled split session ids change', async () => { - const onSplitSessionIdsChange = vi.fn(); - const shellRef = createRef(); - const { container } = renderApp({ - sidebar: false, - onSplitSessionIdsChange, - shellRef, - }); + it('does not clear a session opened while leaving the Session Overview', async () => { + let resolveClear!: () => void; + mockSessionActions.clearSession.mockReturnValueOnce( + new Promise((resolve) => { + resolveClear = resolve; + }), + ); + const onSessionIdChange = vi.fn(); + const { container, rerender } = renderApp({ onSessionIdChange }); await flush(); await act(async () => { - shellRef.current?.openSplitView(); + container + .querySelector( + '[data-testid="open-sessions-overview"]', + ) + ?.click(); await Promise.resolve(); }); + act(() => { + container + .querySelector('[data-testid="panel-back"]') + ?.click(); + }); + expect(mockSessionActions.clearSession).toHaveBeenCalledOnce(); + await act(async () => { container - .querySelector('[data-testid="split-report-panes"]') + .querySelector('[data-testid="load-session"]') ?.click(); await Promise.resolve(); }); + mockConnection.sessionId = 'session-2'; + rerender(); + await flush(); + onSessionIdChange.mockClear(); + await act(async () => { + resolveClear(); + await Promise.resolve(); + }); - expect(onSplitSessionIdsChange).toHaveBeenCalledWith(['s1', 's2', 's3']); + expect(onSessionIdChange).not.toHaveBeenCalled(); }); - it('opens the split view from the external shell ref like the sidebar button', async () => { - let shellApi: WebShellApi | null = null; - const { container } = renderApp({ - sidebar: false, - shellRef: (api) => { - shellApi = api; - }, + it('forwards the session-source switch customization to the sidebar', async () => { + const { container, rerender } = renderApp({ + sidebar: { enabled: true, showSessionSourceSwitch: false }, }); await flush(); - expect(container.querySelector('[data-testid="sidebar"]')).toBeNull(); + expect( + container + .querySelector('[data-testid="sidebar"]') + ?.getAttribute('data-show-session-source-switch'), + ).toBe('false'); + + rerender({ sidebar: { enabled: true } }); + await flush(); - await act(async () => { - shellApi?.openSplitView(); - await Promise.resolve(); - }); expect( - container.querySelector('[data-testid="split-view-page"]'), - ).not.toBeNull(); + container + .querySelector('[data-testid="sidebar"]') + ?.getAttribute('data-show-session-source-switch'), + ).toBe('true'); + + rerender({ sidebar: true }); + await flush(); + expect( - container.querySelector('[data-testid="split-initial"]')?.textContent, - ).toBe('session-1'); + container + .querySelector('[data-testid="sidebar"]') + ?.getAttribute('data-show-session-source-switch'), + ).toBe('true'); }); - it('requests controlled split ids from the external shell ref', async () => { - const onSplitSessionIdsChange = vi.fn(); - const shellRef = createRef(); - const { container } = renderApp({ - sidebar: false, - splitSessionIds: [], - onSplitSessionIdsChange, - shellRef, - }); + it('opens the split view from the sidebar', async () => { + const { container } = renderApp(); await flush(); await act(async () => { - shellRef.current?.openSplitView(); + container + .querySelector('[data-testid="open-split-view"]') + ?.click(); await Promise.resolve(); }); - - expect(onSplitSessionIdsChange).toHaveBeenCalledWith(['session-1']); expect( container.querySelector('[data-testid="split-view-page"]'), - ).toBeNull(); + ).not.toBeNull(); + // The outer chat subtree is hidden (display:none + aria-hidden) behind the + // split, so keyboard/AT can't reach the outer composer/toolbar. Assert the + // node is present first, so a missing subtree fails rather than passing + // vacuously through the optional chain. + const messages = container.querySelector('[data-testid="messages"]'); + expect(messages).not.toBeNull(); + expect(messages?.closest('[aria-hidden="true"]')).not.toBeNull(); }); - it('assigns and clears the external shell object ref', async () => { - const shellRef = createRef(); - const { unmount } = renderApp({ - sidebar: false, - shellRef, - }); - await flush(); - - expect(shellRef.current).not.toBeNull(); + it('preserves the legacy split Voice workspace fallback', async () => { + mockWorkspace.capabilities = { + features: ['voice_transcribe'], + workspaceCwd: '/workspace', + } as typeof mockWorkspace.capabilities; + saveSplitSessions(['s1']); - unmount(); + const { container } = renderApp(); + await flush(); - expect(shellRef.current).toBeNull(); + expect( + container.querySelector('[data-testid="split-voice-workspaces"]') + ?.textContent, + ).toBe('legacy'); }); - 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 }); + it('restores a persisted split on load (survives a refresh)', async () => { + // Simulate the storage left behind by a split that was open before a refresh. + saveSplitSessions(['s1', 's2']); + const { container } = renderApp(); await flush(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="split-initial"]')?.textContent, + ).toBe('s1,s2'); + }); - let created = false; - act(() => { - created = shellRef.current?.createSideTask() ?? false; - }); + it('does not open the split when nothing was persisted', async () => { + const { container } = renderApp(); + await flush(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + }); - expect(created).toBe(true); - expect(container.querySelector('button[title="Side task"]')).not.toBeNull(); + it('clears the persisted split when the user leaves the split view', async () => { + saveSplitSessions(['s1', 's2']); + const { container } = renderApp(); await flush(); - expect(sessionCatalogController.sessionCreated).toHaveBeenCalledWith( - '/tmp/project', - 'side-session-1', - ); + // Restored into the split; leaving via its back button must clear storage + // so a later refresh doesn't bring the split back uninvited. + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + await act(async () => { + container + .querySelector('[data-testid="split-back"]') + ?.click(); + await Promise.resolve(); + }); + expect(loadSplitSessions()).toEqual([]); }); - it('opens the Session Overview from the external shell ref like the sidebar button', async () => { - let shellApi: WebShellApi | null = null; - const { container } = renderApp({ + it('syncs the split view from external session ids without the sidebar', async () => { + const { container, rerender } = renderApp({ sidebar: false, - shellRef: (api) => { - shellApi = api; - }, + splitSessionIds: ['s1'], + renderPaneHeaderActions: () => null, }); await flush(); expect(container.querySelector('[data-testid="sidebar"]')).toBeNull(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="split-initial"]')?.textContent, + ).toBe('s1'); + expect( + container.querySelector('[data-testid="split-has-header-actions"]') + ?.textContent, + ).toBe('yes'); - await act(async () => { - shellApi?.openSessionOverview(); - await Promise.resolve(); + rerender({ sidebar: false, splitSessionIds: ['s1', 's2'] }); + await flush(); + expect( + container.querySelector('[data-testid="split-initial"]')?.textContent, + ).toBe('s1,s2'); + + rerender({ sidebar: false, splitSessionIds: [] }); + await flush(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + + rerender({ sidebar: false, splitSessionIds: ['s1', 's2'] }); + await flush(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="split-initial"]')?.textContent, + ).toBe('s1,s2'); + }); + + it('dedupes and caps external split session ids', async () => { + const { container } = renderApp({ + sidebar: false, + splitSessionIds: ['s1', 's1', 's2', 's3', 's4', 's5', 's6', 's7'], }); + await flush(); - const panel = container.querySelector('[data-testid="inline-panel"]'); - expect(panel).not.toBeNull(); - expect(panel?.getAttribute('aria-label')).toBe('Session Overview'); + expect( + container.querySelector('[data-testid="split-initial"]')?.textContent, + ).toBe('s1,s2,s3,s4,s5,s6'); }); - it('forces the compact session drawer from the external shell ref', async () => { - const shellRef = createRef(); - const { container } = renderApp({ sidebar: true, shellRef }); + it('opens the token usage panel from the default pane header action', async () => { + const statsFixture: DaemonSessionStatsStatus = { + v: 1, + sessionId: 's1', + workspaceCwd: '/tmp/project', + sessionStartTimeMs: 1000, + durationMs: 60000, + promptCount: 2, + models: { + 'qwen-plus::hybrid': { + api: { totalRequests: 2, totalErrors: 0, totalLatencyMs: 1000 }, + tokens: { + prompt: 500000, + candidates: 100000, + total: 600000, + cached: 50000, + thoughts: 10000, + }, + }, + }, + tools: { + totalCalls: 3, + totalSuccess: 3, + totalFail: 0, + totalDurationMs: 900, + byName: {}, + }, + files: { totalLinesAdded: 12, totalLinesRemoved: 4 }, + sources: [], + }; + mockSessionActions.getStats.mockResolvedValue(statsFixture); + const { container } = renderApp({ + sidebar: false, + splitSessionIds: ['s1'], + header: { items: ['tokenUsage'] }, + }); await flush(); + const entry = container.querySelector( + '[aria-label="Session token usage"]', + ); + expect(entry).not.toBeNull(); await act(async () => { - shellRef.current?.openSessionDrawer(); + entry!.click(); await Promise.resolve(); }); + await flush(); - const drawer = container.querySelector( - '[data-sidebar-shell][role="dialog"]', - ); - expect(drawer).not.toBeNull(); - expect(drawer?.className).toContain('mobileDrawerForced'); + expect(mockSessionActions.getStats).toHaveBeenCalled(); + // The right panel (floating drawer on split view) opened with the token + // usage tab and its live data, portaled into document.body. + expect(document.body.textContent).toContain('Token Usage'); + expect(document.body.textContent).toContain('qwen-plus::hybrid'); + expect(document.body.textContent).toContain('600K'); + + await act(async () => { + container + .querySelector('[data-testid="split-remove-panes"]')! + .click(); + }); + expect(document.body.textContent).not.toContain('qwen-plus::hybrid'); }); - it('closes the forced compact drawer from the sidebar control', async () => { - const shellRef = createRef(); - const { container } = renderApp({ sidebar: true, shellRef }); + it('does not add a token usage pane action unless it is enabled', async () => { + const { container } = renderApp({ + sidebar: false, + splitSessionIds: ['s1'], + }); await flush(); - await act(async () => { - shellRef.current?.openSessionDrawer(); - await Promise.resolve(); + expect( + container.querySelector('[data-testid="split-has-header-actions"]') + ?.textContent, + ).toBe('no'); + }); + + it('does not reopen controlled split view when the same ids get a new array reference', async () => { + const { container, rerender } = renderApp({ + sidebar: false, + splitSessionIds: ['s1', 's2'], }); + await flush(); expect( - container.querySelector('[data-sidebar-shell][role="dialog"]'), + container.querySelector('[data-testid="split-view-page"]'), ).not.toBeNull(); await act(async () => { container - .querySelector( - '[data-testid="close-mobile-sidebar"]', - ) + .querySelector('[data-testid="split-back"]') ?.click(); await Promise.resolve(); }); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + expect( + container + .querySelector('[data-testid="inline-panel"]') + ?.getAttribute('aria-label'), + ).toBe('Session Overview'); + rerender({ sidebar: false, splitSessionIds: ['s1', 's2'] }); + await flush(); expect( - container.querySelector('[data-sidebar-shell][role="dialog"]'), + container.querySelector('[data-testid="split-view-page"]'), ).toBeNull(); expect( - container.querySelector('[data-sidebar-shell]')?.className, - ).not.toContain('mobileDrawerForced'); + container + .querySelector('[data-testid="inline-panel"]') + ?.getAttribute('aria-label'), + ).toBe('Session Overview'); }); - it('does not open or lock scrolling when the sidebar is disabled', async () => { - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'auto'; - - try { - const shellRef = createRef(); - const { container } = renderApp({ sidebar: false, shellRef }); - await flush(); + it('notifies external callers when split session ids change inside WebShell', async () => { + const onSplitSessionIdsChange = vi.fn(); + const { container, rerender } = renderApp({ + sidebar: false, + splitSessionIds: ['s1'], + onSplitSessionIdsChange, + }); + await flush(); + + await act(async () => { + container + .querySelector('[data-testid="split-report-panes"]') + ?.click(); + await Promise.resolve(); + }); + + expect(onSplitSessionIdsChange).toHaveBeenCalledWith(['s1', 's2', 's3']); + expect( + container.querySelector('[data-testid="split-initial"]')?.textContent, + ).toBe('s1'); + + rerender({ + sidebar: false, + splitSessionIds: ['s1', 's2', 's3'], + onSplitSessionIdsChange, + }); + await flush(); + expect( + container.querySelector('[data-testid="split-initial"]')?.textContent, + ).toBe('s1,s2,s3'); + }); + + it('notifies external callers when uncontrolled split session ids change', async () => { + const onSplitSessionIdsChange = vi.fn(); + const shellRef = createRef(); + const { container } = renderApp({ + sidebar: false, + onSplitSessionIdsChange, + shellRef, + }); + await flush(); + + await act(async () => { + shellRef.current?.openSplitView(); + await Promise.resolve(); + }); + await act(async () => { + container + .querySelector('[data-testid="split-report-panes"]') + ?.click(); + await Promise.resolve(); + }); + + expect(onSplitSessionIdsChange).toHaveBeenCalledWith(['s1', 's2', 's3']); + }); + + it('opens the split view from the external shell ref like the sidebar button', async () => { + let shellApi: WebShellApi | null = null; + const { container } = renderApp({ + sidebar: false, + shellRef: (api) => { + shellApi = api; + }, + }); + await flush(); + + expect(container.querySelector('[data-testid="sidebar"]')).toBeNull(); + + await act(async () => { + shellApi?.openSplitView(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="split-initial"]')?.textContent, + ).toBe('session-1'); + }); + + it('requests controlled split ids from the external shell ref', async () => { + const onSplitSessionIdsChange = vi.fn(); + const shellRef = createRef(); + const { container } = renderApp({ + sidebar: false, + splitSessionIds: [], + onSplitSessionIdsChange, + shellRef, + }); + await flush(); + + await act(async () => { + shellRef.current?.openSplitView(); + await Promise.resolve(); + }); + + expect(onSplitSessionIdsChange).toHaveBeenCalledWith(['session-1']); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + }); + + it('assigns and clears the external shell object ref', async () => { + const shellRef = createRef(); + const { unmount } = renderApp({ + sidebar: false, + shellRef, + }); + await flush(); + + expect(shellRef.current).not.toBeNull(); + + unmount(); + + expect(shellRef.current).toBeNull(); + }); + + 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(); + + let created = false; + act(() => { + created = shellRef.current?.createSideTask() ?? false; + }); + + 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 () => { + let shellApi: WebShellApi | null = null; + const { container } = renderApp({ + sidebar: false, + shellRef: (api) => { + shellApi = api; + }, + }); + await flush(); + + expect(container.querySelector('[data-testid="sidebar"]')).toBeNull(); + + await act(async () => { + shellApi?.openSessionOverview(); + await Promise.resolve(); + }); + + const panel = container.querySelector('[data-testid="inline-panel"]'); + expect(panel).not.toBeNull(); + expect(panel?.getAttribute('aria-label')).toBe('Session Overview'); + }); + + it('forces the compact session drawer from the external shell ref', async () => { + const shellRef = createRef(); + const { container } = renderApp({ sidebar: true, shellRef }); + await flush(); + + await act(async () => { + shellRef.current?.openSessionDrawer(); + await Promise.resolve(); + }); + + const drawer = container.querySelector( + '[data-sidebar-shell][role="dialog"]', + ); + expect(drawer).not.toBeNull(); + expect(drawer?.className).toContain('mobileDrawerForced'); + }); + + it('closes the forced compact drawer from the sidebar control', async () => { + const shellRef = createRef(); + const { container } = renderApp({ sidebar: true, shellRef }); + await flush(); + + await act(async () => { + shellRef.current?.openSessionDrawer(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-sidebar-shell][role="dialog"]'), + ).not.toBeNull(); + + await act(async () => { + container + .querySelector( + '[data-testid="close-mobile-sidebar"]', + ) + ?.click(); + await Promise.resolve(); + }); + + expect( + container.querySelector('[data-sidebar-shell][role="dialog"]'), + ).toBeNull(); + expect( + container.querySelector('[data-sidebar-shell]')?.className, + ).not.toContain('mobileDrawerForced'); + }); + + it('does not open or lock scrolling when the sidebar is disabled', async () => { + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'auto'; + + try { + const shellRef = createRef(); + const { container } = renderApp({ sidebar: false, shellRef }); + await flush(); await act(async () => { shellRef.current?.openSessionDrawer(); @@ -19605,7 +19982,326 @@ describe('App session callbacks', () => { ).not.toBeNull(); }); - it('auto-closes the split view when the screen shrinks below the breakpoint', async () => { + it('auto-closes the split view when the screen shrinks below the breakpoint', async () => { + let large = true; + let changeHandler: ((event: { matches: boolean }) => void) | undefined; + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + get matches() { + return query.includes('min-width') ? large : false; + }, + media: query, + addEventListener: ( + _type: string, + cb: (event: { matches: boolean }) => void, + ) => { + if (query.includes('1024')) changeHandler = cb; + }, + removeEventListener: vi.fn(), + })), + }); + + const { container } = renderApp(); + await flush(); + + await act(async () => { + container + .querySelector('[data-testid="open-split-view"]') + ?.click(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + + await act(async () => { + large = false; + changeHandler?.({ matches: false }); + await Promise.resolve(); + }); + // Shrinking below the large-screen breakpoint folds the split back to chat. + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + }); + + it('notifies controlled callers when a screen shrink closes the split view', async () => { + let large = true; + let changeHandler: ((event: { matches: boolean }) => void) | undefined; + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + get matches() { + return query.includes('min-width') ? large : false; + }, + media: query, + addEventListener: ( + _type: string, + cb: (event: { matches: boolean }) => void, + ) => { + if (query.includes('1024')) changeHandler = cb; + }, + removeEventListener: vi.fn(), + })), + }); + const onSplitSessionIdsChange = vi.fn(); + + const { container } = renderApp({ + sidebar: false, + splitSessionIds: ['s1', 's2'], + onSplitSessionIdsChange, + }); + await flush(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + + await act(async () => { + large = false; + changeHandler?.({ matches: false }); + await Promise.resolve(); + }); + + expect(onSplitSessionIdsChange).toHaveBeenCalledWith([]); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + }); + + it('folds the split without switching the chat session on shrink', async () => { + let large = true; + let changeHandler: ((event: { matches: boolean }) => void) | undefined; + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + get matches() { + return query.includes('min-width') ? large : false; + }, + media: query, + addEventListener: ( + _type: string, + cb: (event: { matches: boolean }) => void, + ) => { + if (query.includes('1024')) changeHandler = cb; + }, + removeEventListener: vi.fn(), + })), + }); + mockConnection.sessionId = 'session-1'; + window.history.replaceState(null, '', '/?split=s1,s2'); + + try { + const { container } = renderApp(); + await flush(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + + await act(async () => { + large = false; + changeHandler?.({ matches: false }); + await Promise.resolve(); + }); + + // The split folds back to chat, but folding must leave the chat's own + // connection untouched — switching sessions here would drop its session / + // git-branch / URL context and break the lossless restore on regrow. + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + expect(mockSessionActions.loadSession).not.toHaveBeenCalled(); + } finally { + window.history.replaceState(null, '', '/'); + } + }); + + it('restores the split view when the screen grows back after a shrink', async () => { + let large = true; + let changeHandler: ((event: { matches: boolean }) => void) | undefined; + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + get matches() { + return query.includes('min-width') ? large : false; + }, + media: query, + addEventListener: ( + _type: string, + cb: (event: { matches: boolean }) => void, + ) => { + if (query.includes('1024')) changeHandler = cb; + }, + removeEventListener: vi.fn(), + })), + }); + window.history.replaceState(null, '', '/?split=s1,s2'); + + try { + const { container } = renderApp(); + await flush(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + + // Shrinking below the breakpoint folds the split away... + await act(async () => { + large = false; + changeHandler?.({ matches: false }); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + + // ...and growing back past it restores the same split (a transient resize + // is lossless, not a permanent drop of the panes). + await act(async () => { + large = true; + changeHandler?.({ matches: true }); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + } finally { + window.history.replaceState(null, '', '/'); + } + }); + + it('defers split restoration while the Session Overview is open', async () => { + let large = true; + let changeHandler: ((event: { matches: boolean }) => void) | undefined; + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + get matches() { + return query.includes('min-width') ? large : false; + }, + media: query, + addEventListener: ( + _type: string, + cb: (event: { matches: boolean }) => void, + ) => { + if (query.includes('1024')) changeHandler = cb; + }, + removeEventListener: vi.fn(), + })), + }); + window.history.replaceState(null, '', '/?split=s1,s2'); + + try { + const { container } = renderApp(); + await flush(); + await act(async () => { + large = false; + changeHandler?.({ matches: false }); + await Promise.resolve(); + }); + await act(async () => { + container + .querySelector( + '[data-testid="open-sessions-overview"]', + ) + ?.click(); + await Promise.resolve(); + }); + + await act(async () => { + large = true; + changeHandler?.({ matches: true }); + await Promise.resolve(); + }); + const panel = container.querySelector('[data-testid="inline-panel"]'); + expect(panel).not.toBeNull(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + + await act(async () => { + panel?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + } finally { + window.history.replaceState(null, '', '/'); + } + }); + + it('does not restore a folded split when leaving the Session Overview with Back', async () => { + let large = true; + let changeHandler: ((event: { matches: boolean }) => void) | undefined; + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + get matches() { + return query.includes('min-width') ? large : false; + }, + media: query, + addEventListener: ( + _type: string, + cb: (event: { matches: boolean }) => void, + ) => { + if (query.includes('1024')) changeHandler = cb; + }, + removeEventListener: vi.fn(), + })), + }); + window.history.replaceState(null, '', '/?split=s1,s2'); + + try { + const { container } = renderApp(); + await flush(); + await act(async () => { + large = false; + changeHandler?.({ matches: false }); + await Promise.resolve(); + }); + await act(async () => { + container + .querySelector( + '[data-testid="open-sessions-overview"]', + ) + ?.click(); + await Promise.resolve(); + }); + + await act(async () => { + large = true; + changeHandler?.({ matches: true }); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + + // Back is explicit navigation, not a plain dismiss: the folded split + // must not be restored over the fresh chat. + await act(async () => { + container + .querySelector('[data-testid="panel-back"]') + ?.click(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + } finally { + window.history.replaceState(null, '', '/'); + } + }); + + it('does not restore a folded split when opening a session from the Session Overview', async () => { let large = true; let changeHandler: ((event: { matches: boolean }) => void) | undefined; Object.defineProperty(window, 'matchMedia', { @@ -19624,32 +20320,54 @@ describe('App session callbacks', () => { removeEventListener: vi.fn(), })), }); + window.history.replaceState(null, '', '/?split=s1,s2'); - const { container } = renderApp(); - await flush(); + try { + const { container } = renderApp(); + await flush(); + await act(async () => { + large = false; + changeHandler?.({ matches: false }); + await Promise.resolve(); + }); + await act(async () => { + container + .querySelector( + '[data-testid="open-sessions-overview"]', + ) + ?.click(); + await Promise.resolve(); + }); - await act(async () => { - container - .querySelector('[data-testid="open-split-view"]') - ?.click(); - await Promise.resolve(); - }); - expect( - container.querySelector('[data-testid="split-view-page"]'), - ).not.toBeNull(); + await act(async () => { + large = true; + changeHandler?.({ matches: true }); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); - await act(async () => { - large = false; - changeHandler?.({ matches: false }); - await Promise.resolve(); - }); - // Shrinking below the large-screen breakpoint folds the split back to chat. - expect( - container.querySelector('[data-testid="split-view-page"]'), - ).toBeNull(); + // Opening a session is explicit navigation: the folded split must not + // be restored over the session the user chose. + await act(async () => { + await testState.latestSessionOverviewProps?.onOpenSession?.( + 'session-2', + ); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + } finally { + window.history.replaceState(null, '', '/'); + } }); - it('notifies controlled callers when a screen shrink closes the split view', async () => { + it('does not restore a folded split when the sidebar starts a new chat', async () => { let large = true; let changeHandler: ((event: { matches: boolean }) => void) | undefined; Object.defineProperty(window, 'matchMedia', { @@ -19668,31 +20386,57 @@ describe('App session callbacks', () => { removeEventListener: vi.fn(), })), }); - const onSplitSessionIdsChange = vi.fn(); - - const { container } = renderApp({ - sidebar: false, - splitSessionIds: ['s1', 's2'], - onSplitSessionIdsChange, - }); - await flush(); - expect( - container.querySelector('[data-testid="split-view-page"]'), - ).not.toBeNull(); + window.history.replaceState(null, '', '/?split=s1,s2'); - await act(async () => { - large = false; - changeHandler?.({ matches: false }); - await Promise.resolve(); - }); + try { + const { container } = renderApp(); + await flush(); + await act(async () => { + large = false; + changeHandler?.({ matches: false }); + await Promise.resolve(); + }); + await act(async () => { + container + .querySelector( + '[data-testid="open-sessions-overview"]', + ) + ?.click(); + await Promise.resolve(); + }); + await act(async () => { + large = true; + changeHandler?.({ matches: true }); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); - expect(onSplitSessionIdsChange).toHaveBeenCalledWith([]); - expect( - container.querySelector('[data-testid="split-view-page"]'), - ).toBeNull(); + // New chat is explicit navigation: the folded split must not be + // restored over the fresh chat. + await act(async () => { + container + .querySelector('[data-testid="new-session"]') + ?.click(); + await Promise.resolve(); + }); + await flush(); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + } finally { + window.history.replaceState(null, '', '/'); + } }); - it('folds the split without switching the chat session on shrink', async () => { + it('does not restore a folded split when the sidebar loads a session', async () => { let large = true; let changeHandler: ((event: { matches: boolean }) => void) | undefined; Object.defineProperty(window, 'matchMedia', { @@ -19711,35 +20455,57 @@ describe('App session callbacks', () => { removeEventListener: vi.fn(), })), }); - mockConnection.sessionId = 'session-1'; window.history.replaceState(null, '', '/?split=s1,s2'); try { const { container } = renderApp(); await flush(); - expect( - container.querySelector('[data-testid="split-view-page"]'), - ).not.toBeNull(); - await act(async () => { large = false; changeHandler?.({ matches: false }); await Promise.resolve(); }); + await act(async () => { + container + .querySelector( + '[data-testid="open-sessions-overview"]', + ) + ?.click(); + await Promise.resolve(); + }); + await act(async () => { + large = true; + changeHandler?.({ matches: true }); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); - // The split folds back to chat, but folding must leave the chat's own - // connection untouched — switching sessions here would drop its session / - // git-branch / URL context and break the lossless restore on regrow. + // Loading a session is explicit navigation: the folded split must not + // be restored over the session the user chose. + await act(async () => { + container + .querySelector('[data-testid="load-session"]') + ?.click(); + await Promise.resolve(); + }); + await flush(); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).toBeNull(); expect( container.querySelector('[data-testid="split-view-page"]'), ).toBeNull(); - expect(mockSessionActions.loadSession).not.toHaveBeenCalled(); } finally { window.history.replaceState(null, '', '/'); } }); - it('restores the split view when the screen grows back after a shrink', async () => { + it('does not restore a folded split when the sidebar selects the current session', async () => { let large = true; let changeHandler: ((event: { matches: boolean }) => void) | undefined; Object.defineProperty(window, 'matchMedia', { @@ -19763,35 +20529,97 @@ describe('App session callbacks', () => { try { const { container } = renderApp(); await flush(); - expect( - container.querySelector('[data-testid="split-view-page"]'), - ).not.toBeNull(); - - // Shrinking below the breakpoint folds the split away... await act(async () => { large = false; changeHandler?.({ matches: false }); await Promise.resolve(); }); - expect( - container.querySelector('[data-testid="split-view-page"]'), - ).toBeNull(); - - // ...and growing back past it restores the same split (a transient resize - // is lossless, not a permanent drop of the panes). + await act(async () => { + container + .querySelector( + '[data-testid="open-sessions-overview"]', + ) + ?.click(); + await Promise.resolve(); + }); await act(async () => { large = true; changeHandler?.({ matches: true }); await Promise.resolve(); }); + + await act(async () => { + container + .querySelector( + '[data-testid="select-current-session"]', + ) + ?.click(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).toBeNull(); expect( container.querySelector('[data-testid="split-view-page"]'), - ).not.toBeNull(); + ).toBeNull(); } finally { window.history.replaceState(null, '', '/'); } }); + it('does not hand out the overview split action below the large-screen breakpoint', async () => { + let large = false; + let changeHandler: ((event: { matches: boolean }) => void) | undefined; + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + get matches() { + return query.includes('min-width') ? large : false; + }, + media: query, + addEventListener: ( + _type: string, + cb: (event: { matches: boolean }) => void, + ) => { + if (query.includes('1024')) changeHandler = cb; + }, + removeEventListener: vi.fn(), + })), + }); + + const { container } = renderApp(); + await flush(); + // The overview itself is reachable below the breakpoint... + await act(async () => { + container + .querySelector( + '[data-testid="open-sessions-overview"]', + ) + ?.click(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); + // ...but a split cannot exist there, so the panel must not be handed the + // split action at all. + expect(testState.latestSessionOverviewProps?.onOpenSplit).toBeUndefined(); + // And a stale capture of the action must not fold-loop a phantom split + // into existence that growing the window would resurrect. + await act(async () => { + testState.latestSessionOverviewProps?.onOpenSplit?.(['s1', 's2']); + await Promise.resolve(); + }); + await act(async () => { + large = true; + changeHandler?.({ matches: true }); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + }); + it('auto-collapses the sidebar in a narrow split and expands it when wide', async () => { let wide = false; let changeHandler: ((event: { matches: boolean }) => void) | undefined; @@ -19934,9 +20762,9 @@ describe('App session callbacks', () => { } }); - it('auto-closes the Session Overview when the screen shrinks below the breakpoint', async () => { - // Drive isLargeScreen through a controllable media query: open the panel on - // a large screen, then flip below the breakpoint and confirm it closes. + it('keeps the Session Overview open when the screen shrinks below the breakpoint', async () => { + // The table handles narrow widths itself, so only split view remains gated + // by the large-screen breakpoint. let large = true; let changeHandler: ((event: { matches: boolean }) => void) | undefined; Object.defineProperty(window, 'matchMedia', { @@ -19976,7 +20804,9 @@ describe('App session callbacks', () => { changeHandler?.({ matches: false }); await Promise.resolve(); }); - expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull(); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); }); it('dismisses the Scheduled Tasks page when an approval becomes pending', async () => { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index acd7d6f3a23..5c4a4b977df 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -2084,9 +2084,7 @@ export function App({ setMobileDrawerOpen(false); setForceMobileDrawer(false); }, []); - // The Session Overview panel (mission control for managing many sessions at - // once) is only offered on large screens; below that there is no room for it - // to be useful. + // Split view still needs desktop-scale horizontal room. const isLargeScreen = useIsLargeScreen(); const canDockArtifactPanel = useIsLargeScreen('(min-width: 1001px)'); const prefersReducedMotion = usePrefersReducedMotion(); @@ -5504,6 +5502,8 @@ export function App({ // The user left the split of their own accord, so a refresh must not bring // it back. (A shrink-fold is transient and deliberately doesn't clear it.) clearSplitSessions(); + // Explicit exit also cancels any pending shrink-fold restore. + splitFoldedByShrinkRef.current = false; openPanel('sessions'); }, [notifyControlledSplitClose, openPanel]); // Built-in pane action follows the same tokenUsage opt-in as the chat header. @@ -5561,12 +5561,10 @@ export function App({ } }, [mainView, splitSessionIds, externalSplitControlled]); // If the viewport shrinks below the large-screen breakpoint, fold away the - // Session Overview panel and the split view — both are large-screen-only - // surfaces whose entry points are hidden on small screens. The split is only - // folded, not discarded: growing back past the breakpoint restores it, so a - // transient resize is lossless. When a shrink folds the split, its panes - // unmount and take keyboard focus with them; flag the composer to be refocused - // once the chat is shown again. + // split view. It is only folded, not discarded: growing back past the + // breakpoint restores it, so a transient resize is lossless. When a shrink + // folds the split, its panes unmount and take keyboard focus with them; flag + // the composer to be refocused once the chat is shown again. const focusComposerAfterSplitCloseRef = useRef(false); // True while the split view is only *temporarily* folded away because the // window is narrower than the large-screen breakpoint. Growing back past the @@ -5577,7 +5575,7 @@ export function App({ // Grew back above the breakpoint: restore a split that a shrink folded // away. Standalone/uncontrolled only — a controlled host owns its split // lifecycle and re-opens it itself. - if (splitFoldedByShrinkRef.current) { + if (splitFoldedByShrinkRef.current && !activePanel) { splitFoldedByShrinkRef.current = false; if (!externalSplitControlled && splitSessionIdsRef.current.length > 0) { setMainView((prev) => (prev === 'chat' ? 'split' : prev)); @@ -5585,9 +5583,6 @@ export function App({ } return; } - if (activePanel === 'sessions') { - setActivePanel(null); - } if (mainView === 'split') { notifyControlledSplitClose(); setMainView('chat'); @@ -5613,8 +5608,8 @@ export function App({ } } }, [ - isLargeScreen, activePanel, + isLargeScreen, mainView, notifyControlledSplitClose, externalSplitControlled, @@ -8354,9 +8349,7 @@ export function App({ ]); const handleCycleMode = useCallback(() => { - const idx = isDaemonApprovalMode(currentMode) - ? MODES_CYCLE.indexOf(currentMode) - : -1; + const idx = MODES_CYCLE.findIndex((mode) => mode === currentMode); const next = MODES_CYCLE[(idx + 1) % MODES_CYCLE.length]; handleSetMode(next); }, [currentMode, handleSetMode]); @@ -8517,13 +8510,8 @@ export function App({ const createNewSession = useCallback( async ( workspaceCwd?: string, - /** - * Leave `mainView` alone. The default is to switch to the chat, because a - * user who asks for a new chat wants to see it — but the Goals form has to - * stay mounted until its prompt is admitted, or a rejection has nowhere to - * render. Only that caller passes this. - */ - opts?: { keepView?: boolean }, + /** Preserve the current full-page view and/or active panel while clearing. */ + opts?: { keepView?: boolean; keepPanel?: boolean }, ) => { const targetWorkspaceCwd = lockedWorkspaceCwd ?? workspaceCwd; composerSourceVersionRef.current += 1; @@ -8537,7 +8525,11 @@ export function App({ closeMobileDrawer(); // Starting a new chat means the user wants to see it — leave any open // Settings/Status panel so the fresh chat is visible (no-op when closed). - closePanel(); + if (!opts?.keepPanel) { + // Explicit navigation cancels any pending shrink-fold split restore. + splitFoldedByShrinkRef.current = false; + closePanel(); + } if (!opts?.keepView) setMainView('chat'); let focusRequest: number | undefined; try { @@ -8997,6 +8989,8 @@ export function App({ // Loading another session should reveal its chat, not stay on the // Settings/Status panel (no-op when the panel is closed). closePanel(); + // Explicit navigation cancels any pending shrink-fold split restore. + splitFoldedByShrinkRef.current = false; try { await sessionActions.loadSession(sessionId, { workspaceCwd }); if (sessionOpenInvocationRef.current === invocation) { @@ -9017,6 +9011,8 @@ export function App({ // returns to the chat view and reports load failures. const handleOpenSessionFromOverview = useCallback( (sessionId: string, workspaceCwd?: string) => { + // Explicit navigation cancels any pending shrink-fold split restore. + splitFoldedByShrinkRef.current = false; setMainView('chat'); void loadSidebarSession(sessionId, workspaceCwd).catch( (error: unknown) => { @@ -12408,7 +12404,7 @@ export function App({ closeMobileDrawer(); openPanel('sessions'); }} - canOpenSessionsOverview={isLargeScreen} + canOpenSessionsOverview onOpenSplitView={() => { closeMobileDrawer(); openSplitView(); @@ -12431,6 +12427,7 @@ export function App({ onSelectCurrentSession={() => { closeMobileDrawer(); setMainView('chat'); + splitFoldedByShrinkRef.current = false; closePanel(); }} onSessionRenameConfirmed={reconcileCatalogRename} @@ -12680,7 +12677,44 @@ export function App({ type="button" className={styles.panelBack} data-testid="panel-back" - onClick={closePanel} + onClick={() => { + if (activePanel !== 'sessions') { + closePanel(); + return; + } + // Explicit navigation cancels any pending shrink-fold + // split restore. + splitFoldedByShrinkRef.current = false; + const current = connectionRef.current; + const currentWorkspaceCwd = + current.workspaceCwd || + lockedWorkspaceCwd || + workspacesRef.current.find( + (entry) => entry.primary, + )?.cwd; + const openInvocation = sessionOpenInvocationRef.current; + void createNewSession(currentWorkspaceCwd).then( + (created) => { + const latest = connectionRef.current; + const latestWorkspaceCwd = + latest.workspaceCwd || + lockedWorkspaceCwd || + workspacesRef.current.find( + (entry) => entry.primary, + )?.cwd; + if ( + created && + sessionOpenInvocationRef.current === + openInvocation && + (latest.sessionId === undefined || + (latest.sessionId === current.sessionId && + latestWorkspaceCwd === currentWorkspaceCwd)) + ) { + onSessionIdChange?.(undefined); + } + }, + ); + }} aria-label={t('common.back')} title={t('common.back')} > @@ -12823,9 +12857,52 @@ export function App({ ) : ( { + const current = connectionRef.current; + const currentWorkspaceCwd = + current.workspaceCwd || + lockedWorkspaceCwd || + workspacesRef.current.find( + (entry) => entry.primary, + )?.cwd; + if ( + current.sessionId !== removed.sessionId || + (currentWorkspaceCwd && + currentWorkspaceCwd !== removed.workspaceCwd) + ) { + return; + } + const cleared = await createNewSession( + removed.workspaceCwd || undefined, + { + keepView: true, + keepPanel: true, + }, + ); + const latest = connectionRef.current; + const latestWorkspaceCwd = + latest.workspaceCwd || + lockedWorkspaceCwd || + workspacesRef.current.find( + (entry) => entry.primary, + )?.cwd; + if ( + cleared && + (!latestWorkspaceCwd || + latestWorkspaceCwd === removed.workspaceCwd) && + (latest.sessionId === removed.sessionId || + latest.sessionId === undefined) + ) { + onSessionIdChange?.(undefined); + } + return cleared; + }} includeOtherWorkspaces={!lockedWorkspaceCwd} workspaceCwd={lockedWorkspaceCwd} + manageLiveState={!sidebarOptions.enabled} /> )} diff --git a/packages/web-shell/client/components/SessionOverviewPanel.module.css b/packages/web-shell/client/components/SessionOverviewPanel.module.css index c39075f9084..53479a2d0fc 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.module.css +++ b/packages/web-shell/client/components/SessionOverviewPanel.module.css @@ -6,56 +6,13 @@ color: var(--foreground); } -.toolbar { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; -} - -.count { - color: var(--muted-foreground); - font-size: 12px; -} - -.selectAll { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: var(--muted-foreground); - cursor: pointer; -} - -.actionButton { - appearance: none; - padding: 4px 12px; - border: 1px solid var(--primary); - border-radius: 8px; - background: var(--primary); - color: var(--background); - font-size: 13px; - font-weight: 500; - cursor: pointer; +.panel :global(button:disabled) { + pointer-events: auto; + cursor: not-allowed; } -.actionButton:disabled { - opacity: 0.55; - cursor: default; - border-color: var(--border); - background: transparent; - color: var(--muted-foreground); -} - -.refreshButton { - margin-left: auto; - padding: 4px 12px; - border: 1px solid var(--border); - border-radius: 6px; - background: transparent; - color: var(--foreground); - font-size: 12px; - cursor: pointer; +.tableViewport { + overflow: hidden; } .notice { @@ -66,45 +23,6 @@ background: color-mix(in srgb, var(--warning-color) 12%, transparent); } -/* A responsive grid so the panel fills wide (multi-monitor) screens and still - collapses to a single column on narrow ones. */ -.grid { - list-style: none; - margin: 0; - padding: 0; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 10px; -} - -.card { - display: flex; - flex-direction: column; - gap: 8px; - padding: 12px; - border: 1px solid var(--border); - border-left-width: 3px; - border-radius: 10px; - background: color-mix(in srgb, var(--foreground) 3%, transparent); -} - -.cardCurrent { - outline: 2px solid color-mix(in srgb, var(--primary) 60%, transparent); - outline-offset: -1px; -} - -.cardTop { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} - -.cardCheckbox { - flex: 0 0 auto; - cursor: pointer; -} - .colorDot { width: 8px; height: 8px; @@ -112,119 +30,38 @@ border-radius: 999px; } -.cardLabel { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - text-align: left; - border: none; - background: transparent; - color: var(--foreground); +.empty { + padding: 24px 12px; + text-align: center; + color: var(--muted-foreground); font-size: 13px; - font-weight: 600; - cursor: pointer; - padding: 0; -} - -.cardLabel:hover { - text-decoration: underline; } +/* The "Current" tag on the active session — green so it stands out from the + muted status/workspace badges. */ .currentBadge { - flex: 0 0 auto; - padding: 1px 7px; - border-radius: 999px; - font-size: 11px; - font-weight: 600; - color: var(--primary); - background: color-mix(in srgb, var(--primary) 14%, transparent); -} - -.cardMeta { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - min-width: 0; -} - -.metaItem { - color: var(--muted-foreground); - font-size: 12px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 100%; + color: var(--success-color); + background: color-mix(in srgb, var(--success-color) 14%, transparent); } -.statusBadge { +/* The running indicator mirrors the sidebar's session-loading spinner: only + shown while a turn is active, so idle sessions stay visually quiet. */ +.loading { + width: 12px; + height: 12px; + flex: 0 0 12px; display: inline-flex; - align-items: center; - padding: 1px 9px; + border: 1.5px solid + color-mix(in srgb, var(--muted-foreground) 28%, transparent); + border-top-color: var(--muted-foreground); border-radius: 999px; - font-size: 11px; - font-weight: 600; -} - -/* Status accents — applied both to the card's left border and its badge. */ -.statusApproval { - border-left-color: var(--warning-color); -} - -.statusApproval.statusBadge { - color: var(--warning-color); - background: color-mix(in srgb, var(--warning-color) 14%, transparent); + animation: overviewSpin 0.8s linear infinite; } -.statusRunning { - border-left-color: var(--primary); -} - -.statusRunning.statusBadge { - color: var(--primary); - background: color-mix(in srgb, var(--primary) 14%, transparent); -} - -.statusIdle { - border-left-color: var(--border); -} - -.statusIdle.statusBadge { - color: var(--muted-foreground); - background: color-mix(in srgb, var(--muted-foreground) 12%, transparent); -} - -/* Which workspace a session lives in — only shown on a multi-workspace daemon. - The primary reads muted; a non-primary workspace gets an accent so - cross-workspace sessions stand out in the grid. */ -.workspaceBadge { - flex: 0 0 auto; - display: inline-flex; - align-items: center; - max-width: 140px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - padding: 1px 8px; - border-radius: 999px; - font-size: 11px; - font-weight: 600; - color: var(--muted-foreground); - background: color-mix(in srgb, var(--muted-foreground) 12%, transparent); -} - -.workspaceBadgeOther { - color: var(--primary); - background: color-mix(in srgb, var(--primary) 14%, transparent); -} - -.empty { - padding: 24px 12px; - text-align: center; - color: var(--muted-foreground); - font-size: 13px; +@keyframes overviewSpin { + to { + transform: rotate(360deg); + } } /* Color tags mirror the sidebar's session-group palette. */ diff --git a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx index 2cf0fe201a6..cd752e97c2d 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx @@ -10,6 +10,7 @@ import * as React from 'react'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import type { + DaemonCapabilities, DaemonSessionSummary, DaemonStatusReportSession, } from '@qwen-code/sdk/daemon'; @@ -17,12 +18,25 @@ import { I18nProvider } from '../i18n'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +// jsdom lacks PointerEvent; radix popovers/dropdowns dispatch pointer events +// to open, so the test helper needs a usable constructor (mirrors the sidebar +// tests). +if (!globalThis.PointerEvent) { + globalThis.PointerEvent = MouseEvent as typeof PointerEvent; +} +// radix Select calls hasPointerCapture during pointer handling; jsdom does not +// implement it (mirrors the sidebar tests). +if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; +} + // --- Mutable mock state, reset in beforeEach --- let connectionState: { sessionId?: string; - capabilities?: { features?: string[] }; + capabilities?: DaemonCapabilities; workspaceCwd?: string; }; +let workspaceCapabilities: DaemonCapabilities | undefined; let sessionsState: { sessions: DaemonSessionSummary[]; loading: boolean; @@ -33,20 +47,48 @@ let statusState: { }; // Live sessions the mock daemon returns per non-primary workspace cwd. let otherWorkspaceSessions: Record; +// Options captured from the (mocked) useScopedSessions call, so tests can +// assert the live-state vs catalog-poll fallback. +let scopedSessionsOptions: { pollIntervalMs?: number }; +let workspaceLiveStateOptions: { + enabled: boolean; + workspaceCwds?: string[]; +}; +let statusReportOptions: { autoLoad?: boolean; detail?: string }; +const sessionCatalogController = vi.hoisted(() => ({ + refreshWorkspace: vi.fn(), + renamed: vi.fn(), +})); // 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: { listWorkspaceSessionsPage: ReturnType }; +let workspaceClient: { + listWorkspaceSessionsPage: ReturnType; + workspaceByCwd: ReturnType; + archiveSessionsData: ReturnType; + deleteSessionsData: ReturnType; +}; +// Primary-workspace actions surfaced by useWorkspace / useActions. +let workspaceActions: { + deleteSession: ReturnType; + archiveSession: ReturnType; + renameSession: ReturnType; + exportSession: ReturnType; +}; const sessionsReload = vi.fn(async () => sessionsState.sessions); const statusReload = vi.fn(async () => statusState.report); vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ useConnection: () => connectionState, - useSessions: () => ({ ...sessionsState, reload: sessionsReload }), - useStatusReport: () => ({ ...statusState, reload: statusReload }), + useActions: () => workspaceActions, + useStatusReport: (options: { autoLoad?: boolean; detail?: string } = {}) => { + statusReportOptions = options; + return { ...statusState, reload: statusReload }; + }, useWorkspace: () => ({ client: workspaceClient, - capabilities: connectionState.capabilities, + actions: workspaceActions, + capabilities: workspaceCapabilities ?? connectionState.capabilities, workspaceCwd: connectionState.workspaceCwd, }), })); @@ -56,6 +98,7 @@ vi.mock('../hooks/useScopedSessions', () => ({ _workspaceCwd: string | undefined, options: { pollIntervalMs?: number } = {}, ) => { + scopedSessionsOptions = options; const inFlight = React.useRef(false); React.useEffect(() => { if (options.pollIntervalMs === undefined) return; @@ -72,6 +115,25 @@ vi.mock('../hooks/useScopedSessions', () => ({ }, })); +// The live-state channel itself is exercised by the session-catalog tests; +// the panel just needs its group-catalog return (always empty here). +vi.mock('../session-catalog/workspace-session-live-state', () => ({ + useWorkspaceSessionLiveState: ( + _client: unknown, + options: { enabled: boolean; workspaceCwds?: string[] }, + ) => { + workspaceLiveStateOptions = options; + return new Map(); + }, +})); + +vi.mock('../session-catalog/session-catalog-hooks', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('../session-catalog/session-catalog-hooks') + >()), + useSessionCatalogController: () => sessionCatalogController, +})); + const { SessionOverviewPanel, deriveSessionCards } = await import( './SessionOverviewPanel' ); @@ -110,28 +172,91 @@ function statusSession( let root: Root | null = null; let container: HTMLDivElement | null = null; let onOpenSession: ReturnType; +let onCurrentSessionRemoved: ReturnType; let openSpy: ReturnType; +let anchorClick: ReturnType; const originalOpen = window.open; +const originalAnchorClick = HTMLAnchorElement.prototype.click; beforeEach(() => { + window.localStorage.clear(); connectionState = { sessionId: 's-run', - capabilities: { features: [] }, + capabilities: { + features: ['session_archive', 'workspace_qualified_rest_core'], + workspaceCwd: '/w', + }, workspaceCwd: '/w', }; + workspaceCapabilities = undefined; sessionsState = { sessions: [], loading: false }; statusState = { report: { full: { sessions: [] } } }; otherWorkspaceSessions = {}; + scopedSessionsOptions = {}; + workspaceLiveStateOptions = { enabled: false }; + statusReportOptions = {}; + sessionCatalogController.refreshWorkspace.mockReset(); + sessionCatalogController.renamed.mockReset(); + workspaceActions = { + deleteSession: vi.fn(async () => true), + archiveSession: vi.fn(async () => true), + renameSession: vi.fn(async () => ({ displayName: 'Renamed' })), + exportSession: vi.fn(async () => ({ + content: '', + filename: 'session.html', + mimeType: 'text/html', + format: 'html', + })), + }; workspaceClient = { listWorkspaceSessionsPage: vi.fn(async (cwd: string) => ({ sessions: otherWorkspaceSessions[cwd] ?? [], })), + workspaceByCwd: vi.fn((cwd: string) => ({ + listWorkspaceSessionsPage: vi.fn(async () => ({ + sessions: otherWorkspaceSessions[cwd] ?? [], + })), + archiveSessionsData: vi.fn(async (ids: string[]) => ({ + archived: ids, + alreadyArchived: [], + notFound: [], + errors: [], + })), + deleteSessionsData: vi.fn(async (ids: string[]) => ({ + removed: ids, + notFound: [], + errors: [], + })), + updateSessionMetadata: vi.fn(async () => ({ displayName: 'Renamed' })), + exportSession: vi.fn(async () => ({ + content: '', + filename: 'session.html', + mimeType: 'text/html', + format: 'html', + })), + })), + archiveSessionsData: vi.fn(async (ids: string[]) => ({ + archived: ids, + alreadyArchived: [], + notFound: [], + errors: [], + })), + deleteSessionsData: vi.fn(async (ids: string[]) => ({ + removed: ids, + notFound: [], + errors: [], + })), }; sessionsReload.mockClear(); statusReload.mockClear(); onOpenSession = vi.fn(); + onCurrentSessionRemoved = vi.fn(async () => {}); openSpy = vi.fn().mockReturnValue({ focus: vi.fn() }); window.open = openSpy as unknown as typeof window.open; + anchorClick = vi.fn(); + HTMLAnchorElement.prototype.click = anchorClick; + URL.createObjectURL = vi.fn(() => 'blob:test'); + URL.revokeObjectURL = vi.fn(); }); afterEach(() => { @@ -140,9 +265,20 @@ afterEach(() => { root = null; container = null; window.open = originalOpen; + HTMLAnchorElement.prototype.click = originalAnchorClick; }); -function render(props: { onOpenSplit?: (ids: string[]) => void } = {}): void { +function render( + props: { + onOpenSplit?: (ids: string[]) => void; + onCurrentSessionRemoved?: (session: { + sessionId: string; + workspaceCwd: string; + }) => Promise | boolean | void; + manageLiveState?: boolean; + workspaceCwd?: string; + } = {}, +): void { container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -155,7 +291,17 @@ function render(props: { onOpenSplit?: (ids: string[]) => void } = {}): void { ); } -function rerender(props: { onOpenSplit?: (ids: string[]) => void } = {}): void { +function rerender( + props: { + onOpenSplit?: (ids: string[]) => void; + onCurrentSessionRemoved?: (session: { + sessionId: string; + workspaceCwd: string; + }) => Promise | boolean | void; + manageLiveState?: boolean; + workspaceCwd?: string; + } = {}, +): void { act(() => root!.render( @@ -175,71 +321,116 @@ async function flushAsync(): Promise { }); } -function cardLabels(): string[] { - return Array.from(container!.querySelectorAll('ul li')).map( - (li) => li.querySelectorAll('button')[0]?.textContent?.trim() ?? '', +function rows(): HTMLElement[] { + return Array.from(container!.querySelectorAll('tbody tr')); +} +function rowTitles(): string[] { + return rows().map((tr) => { + const title = tr.querySelector('[data-web-shell-session-title]'); + return title?.textContent?.trim() ?? ''; + }); +} +function selectAllCheckbox(): HTMLElement { + return container!.querySelector( + 'thead [data-slot="checkbox"]', + ) as HTMLElement; +} +function rowCheckbox(row: HTMLElement): HTMLElement { + return row.querySelector('td [data-slot="checkbox"]') as HTMLElement; +} +function rowActionButton(row: HTMLElement, label: string): HTMLButtonElement { + return row.querySelector( + `button[aria-label="${label}"]`, + ) as HTMLButtonElement; +} +function titleTrigger(row: HTMLElement): HTMLElement { + return row.querySelector('[data-web-shell-session-title]') as HTMLElement; +} +function footerButton(label: string): HTMLButtonElement | null { + const footer = container!.querySelector('[data-web-shell-session-footer]'); + return ( + Array.from(footer?.querySelectorAll('button') ?? []).find((button) => + button.textContent?.includes(label), + ) ?? + Array.from(container!.querySelectorAll('button')).find((button) => + button.textContent?.includes(label), + ) ?? + null ); } -function selectAllCheckbox(): HTMLInputElement { - return container!.querySelector('input[type="checkbox"]') as HTMLInputElement; +// Radix popovers/dropdowns/selects open on pointerdown and Tabs activate on +// mousedown; the native click alone is not enough in jsdom (mirrors the +// sidebar's test helper, extended with the mousedown radix needs). +function click(element: HTMLElement): void { + element.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0 }), + ); + element.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, button: 0 }), + ); + element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })); + element.dispatchEvent(new MouseEvent('click', { bubbles: true })); } -function tabButton(): HTMLButtonElement { - return Array.from(container!.querySelectorAll('button')).find((b) => - b.textContent?.includes('Open in new tab'), - ) as HTMLButtonElement; + +function setInputValue(input: HTMLInputElement, value: string): void { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set?.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); } describe('deriveSessionCards', () => { - it('ranks needs-approval above running above idle, then by recency', () => { + it('ranks needs-approval above user-input above running above idle, then by recency', () => { const sessions = [ - session('s-idle', { displayName: 'idle', hasActivePrompt: false }), + session('s-idle', { displayName: 'idle' }), session('s-run', { displayName: 'run', hasActivePrompt: true }), - session('s-appr', { displayName: 'appr', hasActivePrompt: false }), + session('s-q', { displayName: 'q', isWaitingForUserQuestion: true }), + session('s-appr', { displayName: 'appr', isWaitingForPermission: true }), ]; - const status = [statusSession('s-appr', { pendingPermissionCount: 1 })]; - const cards = deriveSessionCards(sessions, status, 's-run'); + const cards = deriveSessionCards(sessions, 's-run'); expect(cards.map((c) => c.sessionId)).toEqual([ 's-appr', + 's-q', 's-run', 's-idle', ]); expect(cards.map((c) => c.status)).toEqual([ 'needsApproval', + 'askUserQuestion', 'running', 'idle', ]); }); - it('needs-approval wins even when the prompt is also active (blocked turn)', () => { - const sessions = [session('s', { hasActivePrompt: true })]; - const status = [ - statusSession('s', { hasActivePrompt: true, pendingPermissionCount: 2 }), - ]; - expect(deriveSessionCards(sessions, status, undefined)[0].status).toBe( - 'needsApproval', - ); + it('ranks newer sessions first when status is equal', () => { + const cards = deriveSessionCards([ + session('older', { updatedAt: '2026-01-01T00:00:00.000Z' }), + session('newer', { updatedAt: '2026-01-02T00:00:00.000Z' }), + ]); + expect(cards.map((card) => card.sessionId)).toEqual(['newer', 'older']); }); - it('treats sessions absent from the status report as idle', () => { - const cards = deriveSessionCards([session('cold')], [], undefined); - expect(cards[0].status).toBe('idle'); + it('needs-approval wins even when the prompt is also active (blocked turn)', () => { + const cards = deriveSessionCards( + [session('s', { hasActivePrompt: true, isWaitingForPermission: true })], + undefined, + ); + expect(cards[0].status).toBe('needsApproval'); }); - it('flags sessions in a non-primary workspace against the primary cwd', () => { + it('user-input wins over a running turn when both flags are set', () => { const cards = deriveSessionCards( - [ - session('a', { workspaceCwd: '/w' }), - session('b', { workspaceCwd: '/wsB' }), - ], - [], + [session('s', { hasActivePrompt: true, isWaitingForUserQuestion: true })], undefined, - '/w', ); - const byId = new Map(cards.map((c) => [c.sessionId, c])); - expect(byId.get('a')?.isNonPrimary).toBe(false); - expect(byId.get('b')?.isNonPrimary).toBe(true); - expect(byId.get('b')?.workspaceCwd).toBe('/wsB'); + expect(cards[0].status).toBe('askUserQuestion'); + }); + + it('treats sessions without live flags as idle', () => { + const cards = deriveSessionCards([session('cold')], undefined); + expect(cards[0].status).toBe('idle'); }); it('labels with displayName, falling back to a short id, and flags current', () => { @@ -248,7 +439,6 @@ describe('deriveSessionCards', () => { session('abcdef1234567890', {}), session('named', { displayName: ' Named ' }), ], - [], 'named', ); const byId = new Map(cards.map((c) => [c.sessionId, c])); @@ -258,49 +448,84 @@ describe('deriveSessionCards', () => { expect(byId.get('abcdef1234567890')!.isCurrent).toBe(false); }); - it('carries model and client count from the status report', () => { + it('uses workspace and id together to identify the current session', () => { const cards = deriveSessionCards( - [session('s', { clientCount: 2 })], - [statusSession('s', { currentModelId: 'qwen-max', clientCount: 2 })], - undefined, + [ + session('same', { workspaceCwd: '/w' }), + session('same', { workspaceCwd: '/other' }), + ], + 'same', + [], + '/other', + ); + expect(cards.find((card) => card.workspaceCwd === '/w')?.isCurrent).toBe( + false, ); - expect(cards[0].model).toBe('qwen-max'); - expect(cards[0].clientCount).toBe(2); + expect( + cards.find((card) => card.workspaceCwd === '/other')?.isCurrent, + ).toBe(true); }); - it('passes bound PRs through to the card', () => { - const prs = [ - { number: 9500, url: 'https://github.com/o/r/pull/9500' }, - { number: 9517, url: 'https://github.com/o/r/pull/9517' }, - ]; - const cards = deriveSessionCards([session('s', { prs })], [], undefined); - expect(cards[0].prs).toEqual(prs); - const bare = deriveSessionCards([session('bare')], [], undefined); - expect(bare[0].prs).toBeUndefined(); + it('uses status-report approval details as a compatibility fallback', () => { + const cards = deriveSessionCards([session('s')], undefined, [ + statusSession('s', { + pendingPermissionCount: 1, + }), + ]); + expect(cards[0].status).toBe('needsApproval'); }); - it('does not expose opaque route ids as model names', () => { + it('uses status-report running details as a compatibility fallback', () => { + const cards = deriveSessionCards([session('s')], undefined, [ + statusSession('s', { hasActivePrompt: true }), + ]); + expect(cards[0].status).toBe('running'); + }); + + it('does not merge status from another workspace with the same session id', () => { const cards = deriveSessionCards( - [session('s')], + [session('s', { workspaceCwd: '/a' })], + undefined, [ statusSession('s', { - currentModelId: 'qwen-route:v1:abcdefghijklmnop', + workspaceCwd: '/b', + pendingPermissionCount: 1, }), ], - undefined, ); + expect(cards[0].status).toBe('idle'); + }); - expect(cards[0].model).toBeUndefined(); + it('passes bound PRs through to the card', () => { + const prs = [ + { number: 9500, url: 'https://github.com/o/r/pull/9500' }, + { number: 9517, url: 'https://github.com/o/r/pull/9517' }, + ]; + const cards = deriveSessionCards([session('s', { prs })], undefined); + expect(cards[0].prs).toEqual(prs); + const bare = deriveSessionCards([session('bare')], undefined); + expect(bare[0].prs).toBeUndefined(); }); }); describe('SessionOverviewPanel', () => { it('renders an empty state when there are no sessions', () => { render(); - expect(container!.textContent).toContain('No sessions yet'); + const empty = container!.querySelector('[data-slot="data-table-empty"]'); + expect(empty?.closest('tbody')).not.toBeNull(); + expect(empty?.textContent).toContain('No sessions yet'); + expect(footerButton('Refresh')).not.toBeNull(); + }); + + it('keeps manual refresh available when the initial load fails', async () => { + sessionsState.error = new Error('offline'); + render(); + act(() => click(footerButton('Refresh')!)); + await flushAsync(); + expect(sessionsReload).toHaveBeenCalledOnce(); }); - it('renders cards ranked with needs-approval first', () => { + it('renders rows ranked with needs-approval first', () => { sessionsState.sessions = [ session('s-idle', { displayName: 'Bravo' }), session('s-run', { displayName: 'Alpha', hasActivePrompt: true }), @@ -312,15 +537,283 @@ describe('SessionOverviewPanel', () => { }, }; render(); - expect(cardLabels()).toEqual(['Charlie', 'Alpha', 'Bravo']); + expect(rowTitles()).toEqual(['Charlie', 'Alpha', 'Bravo']); + }); + + it('shows loading after the title for every non-idle session', () => { + sessionsState.sessions = [ + session('s-run', { displayName: 'Run', hasActivePrompt: true }), + session('s-appr', { + displayName: 'Approval', + isWaitingForPermission: true, + }), + session('s-q', { + displayName: 'Question', + isWaitingForUserQuestion: true, + }), + session('s-idle', { displayName: 'Still' }), + ]; + render(); + for (const label of ['Run', 'Approval', 'Question']) { + const row = rows().find((candidate) => + candidate.textContent?.includes(label), + )!; + expect( + titleTrigger(row).nextElementSibling?.hasAttribute( + 'data-web-shell-session-loading', + ), + ).toBe(true); + } + const idle = rows().find((tr) => tr.textContent?.includes('Still'))!; + expect(idle.querySelector('[data-web-shell-session-loading]')).toBeNull(); + }); + + it('toggles selection when the row is clicked', () => { + sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })]; + render(); + act(() => click(rows()[0]!.querySelectorAll('td')[2] as HTMLElement)); + expect(rowCheckbox(rows()[0]!).getAttribute('data-state')).toBe('checked'); + expect(onOpenSession).not.toHaveBeenCalled(); + + act(() => click(rows()[0]!.querySelectorAll('td')[2] as HTMLElement)); + expect(rowCheckbox(rows()[0]!).getAttribute('data-state')).toBe( + 'unchecked', + ); }); - it('switches the current session when a card label is clicked', () => { + it('keeps the title keyboard-focusable for opening a session', () => { sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })]; render(); - const label = container!.querySelector('ul li button') as HTMLButtonElement; - act(() => label.dispatchEvent(new MouseEvent('click', { bubbles: true }))); + const title = titleTrigger(rows()[0]!); + expect(title.tagName).toBe('BUTTON'); + act(() => click(title)); expect(onOpenSession).toHaveBeenCalledWith('s-run', '/w'); + expect(rowCheckbox(rows()[0]!).getAttribute('data-state')).toBe( + 'unchecked', + ); + }); + + it('shows the full title in a tooltip on hover', async () => { + sessionsState.sessions = [ + session('s1', { displayName: 'A long session title' }), + ]; + vi.useFakeTimers(); + try { + render(); + await act(async () => { + titleTrigger(rows()[0]!).dispatchEvent( + new Event('pointermove', { bubbles: true }), + ); + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); + expect( + document.querySelector('[data-slot="tooltip-content"]')?.textContent, + ).toContain('A long session title'); + expect( + document + .querySelector('[data-slot="tooltip-arrow"]') + ?.getAttribute('viewBox'), + ).toBe('0 0 30 10'); + } finally { + vi.useRealTimers(); + } + }); + + it('shows the full session id in a tooltip on hover', async () => { + sessionsState.sessions = [session('session-id-for-tooltip')]; + vi.useFakeTimers(); + try { + render(); + await act(async () => { + rows()[0]! + .querySelector('[data-web-shell-session-id]')! + .dispatchEvent(new Event('pointermove', { bubbles: true })); + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); + expect( + document.querySelector('[data-slot="tooltip-content"]')?.textContent, + ).toContain('session-id-for-tooltip'); + expect( + rows()[0]! + .querySelector('[data-web-shell-session-id]')! + .className.includes('truncate'), + ).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('shows the full workspace path in a tooltip on hover', async () => { + sessionsState.sessions = [ + session('s1', { workspaceCwd: '/workspace/with/a/long/path' }), + ]; + vi.useFakeTimers(); + try { + render(); + await act(async () => { + rows()[0]! + .querySelector('[data-web-shell-session-workspace]')! + .dispatchEvent(new Event('pointermove', { bubbles: true })); + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); + expect( + document.querySelector('[data-slot="tooltip-content"]')?.textContent, + ).toContain('/workspace/with/a/long/path'); + } finally { + vi.useRealTimers(); + } + }); + + it('shows worktree metadata in a column immediately after the title', () => { + sessionsState.sessions = [ + session('s1', { + displayName: 'One', + worktree: { + slug: 'one', + path: '/wt/one', + branch: 'feature/a-very-long-branch-name', + }, + prs: [ + { number: 121, url: 'https://github.com/o/r/pull/121' }, + { number: 122, url: 'https://github.com/o/r/pull/122' }, + { number: 123, url: 'https://github.com/o/r/pull/123' }, + ], + }), + session('s2', { displayName: 'Two' }), + ]; + render(); + const headers = Array.from(container!.querySelectorAll('thead th')); + expect(headers[1]?.textContent).toBe('Title'); + expect(headers[2]?.textContent).toBe('Worktree'); + expect(headers[3]?.textContent).toBe('Session ID'); + expect( + rows()[0]?.querySelector('[data-web-shell-session-git]')?.textContent, + ).toBe('feature/a-very-long-branch-name'); + expect( + rows()[0]?.querySelector('[data-web-shell-session-git] svg'), + ).toBeNull(); + const gitCell = rows()[0] + ?.querySelector('[data-web-shell-session-git]') + ?.closest('td'); + const worktree = rows()[0]?.querySelector('[data-web-shell-session-git]'); + expect(worktree?.className).toContain('min-w-0'); + expect(worktree?.className).toContain('flex-1'); + expect(worktree?.className).toContain('truncate'); + expect(gitCell?.querySelector('a')?.textContent).toBe('#123 +2'); + expect( + rows()[0] + ?.querySelector('[data-web-shell-session-title]') + ?.closest('td') + ?.querySelector('a'), + ).toBeNull(); + expect( + rows()[1]?.querySelector('[data-web-shell-session-git]')?.textContent, + ).toBe('-'); + }); + + it('shows the sidebar details popover from the worktree column', async () => { + vi.useFakeTimers(); + try { + sessionsState.sessions = [ + session('session-details', { + displayName: 'Detailed session', + workspaceCwd: '/work/qwen-code', + updatedAt: '2026-08-26T09:00:00.000Z', + clientCount: 2, + worktree: { + slug: 'details', + path: '/work/qwen-code/.worktrees/details', + branch: 'worktree/details', + }, + prs: [ + { number: 121, url: 'https://github.com/o/r/pull/121' }, + { number: 123, url: 'https://github.com/o/r/pull/123' }, + ], + }), + ]; + render(); + const trigger = container! + .querySelector('[data-web-shell-session-git]')! + .closest('div')!; + await act(async () => { + trigger.dispatchEvent(new Event('pointerover', { bubbles: true })); + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); + + const details = document.querySelector('[role="dialog"]'); + expect(details?.getAttribute('data-align')).toBe('center'); + expect(details?.textContent).toContain('worktree/details'); + expect(details?.textContent).toContain('Pull Request #123'); + expect(details?.textContent).toContain('Pull Request #121'); + expect(details?.textContent).not.toContain('Detailed session'); + expect(details?.textContent).not.toContain('qwen-code'); + expect(details?.textContent).not.toContain('session-details'); + expect(details?.textContent).not.toContain('2 client(s)'); + expect( + details?.querySelectorAll('a[href*="/pull/"]')[0]?.getAttribute('href'), + ).toBe('https://github.com/o/r/pull/123'); + } finally { + vi.useRealTimers(); + } + }); + + it('does not show an empty worktree popover', async () => { + vi.useFakeTimers(); + try { + sessionsState.sessions = [session('no-worktree')]; + render(); + const trigger = container!.querySelector('[data-web-shell-session-git]')!; + await act(async () => { + trigger.dispatchEvent(new Event('pointerover', { bubbles: true })); + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); + + expect(document.querySelector('[role="dialog"]')).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('copies the session ID and restores the hover icon after two seconds', async () => { + vi.useFakeTimers(); + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + try { + sessionsState.sessions = [session('session-to-copy')]; + render(); + const copy = container!.querySelector( + '[data-web-shell-session-id-copy]', + ) as HTMLButtonElement; + expect(copy.className).toContain('opacity-0'); + expect(copy.className).toContain('group-hover:opacity-100'); + expect(copy.querySelector('.lucide-copy')).not.toBeNull(); + + await act(async () => copy.click()); + expect(writeText).toHaveBeenCalledWith('session-to-copy'); + expect(copy.querySelector('.lucide-check')).not.toBeNull(); + expect(copy.className).not.toContain('opacity-0'); + expect(rowCheckbox(rows()[0]!).getAttribute('data-state')).toBe( + 'unchecked', + ); + + act(() => vi.advanceTimersByTime(2000)); + expect(copy.querySelector('.lucide-copy')).not.toBeNull(); + } finally { + act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; + Reflect.deleteProperty(navigator, 'clipboard'); + vi.useRealTimers(); + } }); it("passes the owning workspace cwd when clicking another workspace's session", async () => { @@ -338,45 +831,94 @@ describe('SessionOverviewPanel', () => { ]; render(); await flushAsync(); - const beta = Array.from(container!.querySelectorAll('ul li button')).find( - (b) => b.textContent?.trim() === 'Beta', - ) as HTMLButtonElement; - act(() => beta.dispatchEvent(new MouseEvent('click', { bubbles: true }))); + const beta = rows().find((tr) => tr.textContent?.includes('Beta'))!; + act(() => click(titleTrigger(beta))); expect(onOpenSession).toHaveBeenCalledWith('b1', '/wsB'); }); - it('always shows selection + the "Open in new tab" batch action', () => { + it('keeps equal session ids in different workspaces independent', async () => { + connectionState.sessionId = 'same'; + connectionState.workspaceCwd = ''; + connectionState.capabilities = { + features: ['session_archive', 'workspace_qualified_rest_core'], + workspaceCwd: '/w', + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { id: 'w1', cwd: '/wsB', primary: false, trusted: true }, + ], + }; + sessionsState.sessions = [ + session('same', { workspaceCwd: '/w', displayName: 'Primary' }), + ]; + otherWorkspaceSessions['/wsB'] = [ + session('same', { workspaceCwd: '/wsB', displayName: 'Secondary' }), + ]; + render({ onOpenSplit: vi.fn(), onCurrentSessionRemoved }); + await flushAsync(); + + expect(rowTitles()).toEqual(['Primary', 'Secondary']); + const primary = rows().find((row) => row.textContent?.includes('Primary'))!; + const secondary = rows().find((row) => + row.textContent?.includes('Secondary'), + )!; + expect(primary.textContent).toContain('Current'); + expect(secondary.textContent).not.toContain('Current'); + + act(() => click(rowCheckbox(secondary))); + expect(rowCheckbox(primary).getAttribute('data-state')).toBe('unchecked'); + expect(rowCheckbox(secondary).getAttribute('data-state')).toBe('checked'); + expect(footerButton('Open in new tab')?.disabled).toBe(true); + expect(footerButton('Open in split')?.disabled).toBe(true); + + act(() => click(rowActionButton(secondary, 'Archive'))); + const confirm = document.querySelector( + '[data-slot="alert-dialog-action"]', + ) as HTMLElement; + act(() => click(confirm)); + await flushAsync(); + expect(workspaceClient.workspaceByCwd).toHaveBeenCalledWith('/wsB'); + const qualified = workspaceClient.workspaceByCwd.mock.results.at(-1)!.value; + expect(qualified.archiveSessionsData).toHaveBeenCalledWith(['same']); + expect(workspaceActions.archiveSession).not.toHaveBeenCalled(); + expect(onCurrentSessionRemoved).not.toHaveBeenCalled(); + }); + + it('keeps footer actions visible and disables them until a row is selected', () => { sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })]; - render(); - expect(container!.querySelector('input[type="checkbox"]')).not.toBeNull(); - expect(container!.textContent).toContain('Open in new tab'); - // The removed Window Management tiling affordance is gone. - expect(container!.textContent).not.toContain('Tile across displays'); + render({ onOpenSplit: vi.fn() }); + expect(selectAllCheckbox()).not.toBeNull(); + const actions = [ + footerButton('Archive'), + footerButton('Delete'), + footerButton('Open in new tab'), + footerButton('Open in split'), + ] as HTMLButtonElement[]; + expect(actions.every((button) => button.disabled)).toBe(true); + act(() => click(rowCheckbox(rows()[0]!))); + expect(onOpenSession).not.toHaveBeenCalled(); + expect(container!.textContent).toContain('1 of 1 row(s) selected.'); + const openInTab = footerButton('Open in new tab') as HTMLButtonElement; + expect(actions.every((button) => button.disabled)).toBe(false); + // The new-tab action is not the primary button style. + expect(openInTab.getAttribute('data-variant')).toBe('outline'); }); it('opens the selected sessions as a split in ONE new tab (?split=…)', () => { sessionsState.sessions = [ session('s-idle', { displayName: 'Bravo' }), - session('s-appr', { displayName: 'Charlie' }), + session('s-appr', { + displayName: 'Charlie', + isWaitingForPermission: true, + }), ]; - statusState.report = { - full: { - sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })], - }, - }; render(); - const selectAll = container!.querySelector( - 'input[type="checkbox"]', - ) as HTMLInputElement; - act(() => selectAll.click()); - const tabButton = Array.from(container!.querySelectorAll('button')).find( - (b) => b.textContent?.includes('Open in new tab'), - ) as HTMLButtonElement; - expect(tabButton.disabled).toBe(false); + act(() => click(selectAllCheckbox())); + const openInTab = footerButton('Open in new tab') as HTMLButtonElement; act(() => - tabButton.dispatchEvent(new MouseEvent('click', { bubbles: true })), + openInTab.dispatchEvent(new MouseEvent('click', { bubbles: true })), ); - // A single new tab whose URL carries the ranked split (needs-approval first). + // A single new tab whose URL carries the ranked split (needs-approval + // first). expect(openSpy).toHaveBeenCalledTimes(1); const [url, target] = openSpy.mock.calls[0]; expect(target).toBe('_blank'); @@ -388,170 +930,1644 @@ describe('SessionOverviewPanel', () => { const opened = { focus: vi.fn(), opener: {} as unknown }; openSpy.mockReturnValue(opened); render(); - act(() => selectAllCheckbox().click()); + act(() => click(selectAllCheckbox())); + const openInTab = footerButton('Open in new tab') as HTMLButtonElement; act(() => - tabButton().dispatchEvent(new MouseEvent('click', { bubbles: true })), + openInTab.dispatchEvent(new MouseEvent('click', { bubbles: true })), ); // The opener link is cut so the authenticated split tab can't script us. expect(opened.opener).toBeNull(); expect(opened.focus).toHaveBeenCalledTimes(1); }); - it('opens the selected sessions in split, in ranked order', () => { + it('opens the selected sessions in the visible sort order', () => { sessionsState.sessions = [ - session('s-idle', { displayName: 'Bravo' }), - session('s-appr', { displayName: 'Charlie' }), + session('s-idle', { + displayName: 'Bravo', + updatedAt: '2026-01-01T00:00:00.000Z', + }), + session('s-appr', { + displayName: 'Charlie', + isWaitingForPermission: true, + updatedAt: '2026-01-02T00:00:00.000Z', + }), ]; - statusState.report = { - full: { - sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })], - }, - }; const onOpenSplit = vi.fn(); render({ onOpenSplit }); - const selectAll = container!.querySelector( - 'input[type="checkbox"]', - ) as HTMLInputElement; - act(() => selectAll.click()); - const splitButton = Array.from(container!.querySelectorAll('button')).find( - (b) => b.textContent?.includes('Open in split'), - ) as HTMLButtonElement; + const timeHeader = Array.from(container!.querySelectorAll('th')).find( + (header) => header.textContent?.includes('Time'), + )!; + act(() => click(timeHeader.querySelector('button')!)); + act(() => click(selectAllCheckbox())); + const splitButton = footerButton('Open in split') as HTMLButtonElement; act(() => splitButton.dispatchEvent(new MouseEvent('click', { bubbles: true })), ); - // needs-approval (Charlie) is ranked ahead of idle (Bravo). - expect(onOpenSplit).toHaveBeenCalledWith(['s-appr', 's-idle']); - }); - - it('toggling a card checkbox selects it without switching sessions', () => { - sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })]; - render(); - const cardCheckbox = container!.querySelector( - 'ul li input[type="checkbox"]', - ) as HTMLInputElement; - act(() => cardCheckbox.click()); - expect(cardCheckbox.checked).toBe(true); - // Selecting must not navigate — that's what the label button is for. - expect(onOpenSession).not.toHaveBeenCalled(); + expect(onOpenSplit).toHaveBeenCalledWith(['s-idle', 's-appr']); }); it('surfaces the popup-blocked notice when window.open is blocked', () => { sessionsState.sessions = [session('s1', { displayName: 'One' })]; openSpy.mockReturnValue(null); // browser blocked the pop-up render(); - act(() => selectAllCheckbox().click()); + act(() => click(selectAllCheckbox())); + const openInTab = footerButton('Open in new tab') as HTMLButtonElement; act(() => - tabButton().dispatchEvent(new MouseEvent('click', { bubbles: true })), + openInTab.dispatchEvent(new MouseEvent('click', { bubbles: true })), ); expect(container!.textContent).toContain('Pop-up blocked'); }); - it('surfaces a refresh failure inline while keeping the last-good cards', () => { + it('surfaces a refresh failure inline while keeping the last-good rows', () => { sessionsState.sessions = [session('s1', { displayName: 'One' })]; sessionsState.error = new Error('daemon unreachable'); render(); - // The cards stay on screen (not replaced by the empty/error-only state)… - expect(cardLabels()).toEqual(['One']); + // The rows stay on screen (not replaced by the empty/error-only state)… + expect(rowTitles()).toEqual(['One']); // …and the failure is still visible rather than silently swallowed. expect(container!.textContent).toContain('daemon unreachable'); }); - it('does not re-select a session that left the list and came back', () => { + it('shows a refresh icon and spins while refreshing', async () => { + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + let finishReload: (() => void) | undefined; + sessionsReload.mockImplementationOnce( + () => + new Promise((resolve) => { + finishReload = () => resolve(sessionsState.sessions); + }), + ); + render(); + const refreshButton = footerButton('Refresh') as HTMLButtonElement; + + expect(refreshButton.querySelector('[data-slot="spinner"]')).toBeNull(); + expect(refreshButton.querySelector('svg')).not.toBeNull(); + + act(() => click(refreshButton)); + expect(refreshButton.disabled).toBe(true); + expect(refreshButton.querySelector('[data-slot="spinner"]')).not.toBeNull(); + + await act(async () => { + finishReload?.(); + await Promise.resolve(); + }); + expect(refreshButton.disabled).toBe(false); + expect(refreshButton.querySelector('[data-slot="spinner"]')).toBeNull(); + }); + + it('drops removed session ids while preserving remaining selections', () => { sessionsState.sessions = [ session('a', { displayName: 'A' }), session('b', { displayName: 'B' }), ]; render(); - act(() => selectAllCheckbox().click()); // a + b selected - expect(selectAllCheckbox().checked).toBe(true); + act(() => click(selectAllCheckbox())); + expect(selectAllCheckbox().getAttribute('data-state')).toBe('checked'); - // 'a' leaves the list → its selection is pruned. + // 'a' leaves the list, but the still-present 'b' remains selected. sessionsState.sessions = [session('b', { displayName: 'B' })]; rerender(); - // 'a' comes back → it must NOT be pre-selected (only b remains selected). + expect(selectAllCheckbox().getAttribute('data-state')).toBe('checked'); + expect(container!.textContent).toContain('1 of 1 row(s) selected.'); + sessionsState.sessions = [ session('a', { displayName: 'A' }), session('b', { displayName: 'B' }), ]; rerender(); - expect(selectAllCheckbox().checked).toBe(false); - act(() => - tabButton().dispatchEvent(new MouseEvent('click', { bubbles: true })), - ); - // Only 'b' is opened — the returned 'a' was pruned, not silently reselected. - const url = new URL(String(openSpy.mock.calls[0][0])); - expect(url.searchParams.get('split')).toBe('b'); + expect(container!.textContent).toContain('1 of 2 row(s) selected.'); + const a = rows().find((row) => row.textContent?.includes('A'))!; + expect(rowCheckbox(a).getAttribute('data-state')).toBe('unchecked'); + }); + + it('preserves selection across live status updates', () => { + sessionsState.sessions = [ + session('a', { displayName: 'A' }), + session('b', { displayName: 'B' }), + ]; + render(); + act(() => click(rowCheckbox(rows()[0]!))); + sessionsState.sessions = [ + session('a', { displayName: 'A', hasActivePrompt: true }), + session('b', { displayName: 'B' }), + ]; + rerender(); + expect(container!.textContent).toContain('1 of 2 row(s) selected.'); }); - it('caps the split to 6 sessions and warns when more are selected', () => { + it('disables split actions when more than 6 sessions are selected', () => { sessionsState.sessions = Array.from({ length: 8 }, (_, i) => session(`s${i}`, { displayName: `S${i}` }), ); const onOpenSplit = vi.fn(); render({ onOpenSplit }); - act(() => selectAllCheckbox().click()); // all 8 selected - // A hint tells the user only the first 6 will open. - expect(container!.textContent).toContain('Only the first 6'); - // New-tab URL carries at most 6 ids. - act(() => - tabButton().dispatchEvent(new MouseEvent('click', { bubbles: true })), + act(() => click(selectAllCheckbox())); // all rows selected + const openInTab = footerButton('Open in new tab') as HTMLButtonElement; + const splitButton = footerButton('Open in split') as HTMLButtonElement; + expect(openInTab.disabled).toBe(true); + expect(splitButton.disabled).toBe(true); + expect(openInTab.title).toBe( + 'Select at most 6 sessions to open them together', + ); + expect(openSpy).not.toHaveBeenCalled(); + expect(onOpenSplit).not.toHaveBeenCalled(); + }); + + it('opens all 6 sessions at the split limit', () => { + const expectedIds = Array.from({ length: 6 }, (_, i) => `s${i}`); + sessionsState.sessions = expectedIds.map((id, i) => + session(id, { displayName: `S${i}` }), ); + const onOpenSplit = vi.fn(); + render({ onOpenSplit }); + act(() => click(selectAllCheckbox())); + const openInTab = footerButton('Open in new tab') as HTMLButtonElement; + const splitButton = footerButton('Open in split') as HTMLButtonElement; + expect(openInTab.disabled).toBe(false); + expect(splitButton.disabled).toBe(false); + + act(() => click(openInTab)); const split = new URL(String(openSpy.mock.calls[0][0])).searchParams.get( 'split', ); - expect(split!.split(',')).toHaveLength(6); - // In-window split is likewise capped. - const splitButton = Array.from(container!.querySelectorAll('button')).find( - (b) => b.textContent?.includes('Open in split'), - ) as HTMLButtonElement; - act(() => - splitButton.dispatchEvent(new MouseEvent('click', { bubbles: true })), + expect(split?.split(',').sort()).toEqual(expectedIds); + act(() => click(splitButton)); + expect([...onOpenSplit.mock.calls[0][0]].sort()).toEqual(expectedIds); + }); + + it('keeps the toolbar and pagination responsive on narrow screens', () => { + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + render(); + + const search = container!.querySelector( + 'input[aria-label="Search sessions…"]', + ) as HTMLInputElement; + expect(search.parentElement?.className).toContain('w-full'); + expect(search.parentElement?.className).toContain('max-w-[300px]'); + + const rowsPerPage = Array.from(container!.querySelectorAll('span')).find( + (element) => element.textContent === 'Rows per page', ); - expect(onOpenSplit.mock.calls[0][0]).toHaveLength(6); + expect(rowsPerPage?.parentElement?.className).toContain('flex-wrap'); + expect(rowsPerPage?.parentElement?.className).toContain('min-w-0'); }); - it('lists an other-workspace session as a card with a workspace badge', async () => { - connectionState.capabilities = { - features: [], - workspaceCwd: '/w', - workspaces: [ - { id: 'w0', cwd: '/w', primary: true, trusted: true }, - { - id: 'w1', - cwd: '/wsB', + it('keeps the current page when live session data changes', async () => { + window.localStorage.setItem( + 'qwen-web-shell-session-overview-page-size', + '10', + ); + sessionsState.sessions = Array.from({ length: 15 }, (_, i) => + session(`s${i}`, { + displayName: `S${i}`, + updatedAt: new Date(2026, 0, 15 - i).toISOString(), + }), + ); + render(); + act(() => click(footerButton('Next')!)); + expect(container!.textContent).toContain('Page 2 of 2'); + expect(rows()).toHaveLength(5); + + sessionsState.sessions = sessionsState.sessions.map((item) => + item.sessionId === 's0' ? { ...item, hasActivePrompt: true } : item, + ); + rerender(); + await flushAsync(); + + expect(container!.textContent).toContain('Page 2 of 2'); + expect(rows()).toHaveLength(5); + }); + + it('clamps the current page when the session list shrinks', async () => { + window.localStorage.setItem( + 'qwen-web-shell-session-overview-page-size', + '10', + ); + sessionsState.sessions = Array.from({ length: 15 }, (_, i) => + session(`s${i}`, { displayName: `S${i}` }), + ); + render(); + act(() => click(footerButton('Next')!)); + expect(container!.textContent).toContain('Page 2 of 2'); + + sessionsState.sessions = sessionsState.sessions.slice(0, 10); + rerender(); + await flushAsync(); + expect(container!.textContent).toContain('Page 1 of 1'); + expect(rows()).toHaveLength(10); + }); + + it('selects every session across pages like the old overview', () => { + sessionsState.sessions = Array.from({ length: 60 }, (_, i) => + session(`s${i}`, { displayName: `S${i}` }), + ); + render(); + expect(rows()).toHaveLength(50); + act(() => click(selectAllCheckbox())); + expect(container!.textContent).toContain('60 of 60 row(s) selected.'); + }); + + it('lists an other-workspace session as a row with its folder', async () => { + connectionState.capabilities = { + features: [], + workspaceCwd: '/w', + }; + workspaceCapabilities = { + features: [], + workspaceCwd: '/w', + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { + id: 'w1', + cwd: '/wsB', displayName: 'Payments API', primary: false, trusted: true, }, ], }; - sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })]; + sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })]; + otherWorkspaceSessions['/wsB'] = [ + session('b1', { workspaceCwd: '/wsB', displayName: 'Beta' }), + ]; + render(); + await flushAsync(); // let the other-workspace fan-out resolve + // The non-primary session shows up as its own row… + expect(rowTitles()).toContain('Beta'); + // …with its workspace display name in the folder column. + expect(container!.textContent).toContain('Payments API'); + const primary = rows().find((row) => row.textContent?.includes('Alpha'))!; + expect( + primary.querySelector('[data-web-shell-session-workspace]')?.textContent, + ).toBe('w'); + expect( + container!.querySelector('button[aria-label="Filter by workspace"]'), + ).not.toBeNull(); + }); + + it('does not query other workspaces on a single-workspace daemon', async () => { + sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })]; + render(); + await flushAsync(); + expect(workspaceClient.listWorkspaceSessionsPage).not.toHaveBeenCalled(); + }); + + it('filters rows by search query and shows a no-match state', () => { + sessionsState.sessions = [ + session('a', { displayName: 'Alpha' }), + session('b', { displayName: 'Beta' }), + ]; + render(); + const input = container!.querySelector( + '[aria-label="Search sessions…"]', + ) as HTMLInputElement; + act(() => { + setInputValue(input, 'Alpha'); + }); + expect(rowTitles()).toEqual(['Alpha']); + act(() => { + setInputValue(input, 'zzz'); + }); + const empty = container!.querySelector('[data-slot="data-table-empty"]'); + expect(empty?.closest('tbody')).not.toBeNull(); + expect(empty?.textContent).toContain('No data'); + }); + + it('filters rows by workspace via the multi-select panel', async () => { + connectionState.capabilities = { + features: [], + workspaceCwd: '/w', + workspaces: [ + { + id: 'w0', + cwd: '/w', + displayName: 'Main', + primary: true, + trusted: true, + }, + { + id: 'w1', + cwd: '/wsB', + displayName: 'Payments', + primary: false, + trusted: true, + }, + ], + }; + sessionsState.sessions = [session('a', { displayName: 'Alpha' })]; + otherWorkspaceSessions['/wsB'] = [ + session('b1', { workspaceCwd: '/wsB', displayName: 'Beta' }), + ]; + render(); + await flushAsync(); + expect(rowTitles()).toContain('Alpha'); + expect(rowTitles()).toContain('Beta'); + const trigger = container!.querySelector( + 'button[aria-label="Filter by workspace"]', + ) as HTMLElement; + expect(trigger.closest('th')?.textContent).toContain('Workspace'); + expect(trigger.querySelector('.lucide-funnel')).not.toBeNull(); + act(() => click(trigger)); + const filterPanel = document.querySelector( + '[role="dialog"][aria-label="Filter by workspace"]', + ) as HTMLElement; + expect(filterPanel).not.toBeNull(); + expect(filterPanel.querySelectorAll('[role="checkbox"]')).toHaveLength(3); + expect(filterPanel.textContent).toContain('All'); + expect(filterPanel.textContent).toContain('Main'); + expect(filterPanel.textContent).toContain('Payments'); + const main = filterPanel.querySelector( + '#session-overview-workspace-0', + ) as HTMLElement; + act(() => click(main)); + expect(rowTitles()).toEqual(['Beta']); + + const payments = document.querySelector( + '#session-overview-workspace-1', + ) as HTMLElement; + act(() => click(payments)); + expect( + container!.querySelector('[data-slot="data-table-empty"]')?.textContent, + ).toContain('No data'); + expect( + container!.querySelector('button[aria-label="Filter by workspace"]'), + ).not.toBeNull(); + + const all = document.querySelector( + '#session-overview-workspace-all', + ) as HTMLElement; + act(() => click(all)); + expect(rowTitles()).toEqual(['Alpha', 'Beta']); + }); + + it('hides the workspace filter when the panel is locked to a workspace', () => { + connectionState.capabilities = { + features: [], + workspaceCwd: '/w', + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { id: 'w1', cwd: '/wsB', primary: false, trusted: true }, + ], + }; + sessionsState.sessions = [session('a', { displayName: 'Alpha' })]; + render({ onOpenSplit: undefined }); + // Re-render locked to a single workspace — the filter disappears. + act(() => + root!.render( + + + , + ), + ); + expect( + container!.querySelector('button[aria-label="Filter by workspace"]'), + ).toBeNull(); + }); + + it('drops stale workspace exclusions when the panel locks to the excluded workspace', async () => { + connectionState.capabilities = { + features: [], + workspaceCwd: '/w', + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { id: 'w1', cwd: '/wsB', primary: false, trusted: true }, + ], + }; + sessionsState.sessions = [session('a', { displayName: 'Alpha' })]; + otherWorkspaceSessions['/wsB'] = [ + session('b1', { workspaceCwd: '/wsB', displayName: 'Beta' }), + ]; + render(); + await flushAsync(); + // Exclude /wsB through the funnel filter. + const trigger = container!.querySelector( + 'button[aria-label="Filter by workspace"]', + ) as HTMLElement; + act(() => click(trigger)); + const payments = document.querySelector( + '#session-overview-workspace-1', + ) as HTMLElement; + act(() => click(payments)); + expect(rowTitles()).toEqual(['Alpha']); + + // The host locks the shell to the excluded workspace while the panel + // stays mounted: the filter UI disappears with the lock, so the stale + // exclusion must be reconciled away instead of hiding every row. + sessionsState.sessions = [ + session('b1', { workspaceCwd: '/wsB', displayName: 'Beta' }), + ]; + act(() => + root!.render( + + + , + ), + ); + await flushAsync(); + expect( + container!.querySelector('button[aria-label="Filter by workspace"]'), + ).toBeNull(); + expect(rowTitles()).toEqual(['Beta']); + }); + + it('drops exclusions when the option set shrinks below the funnel threshold', async () => { + connectionState.capabilities = { + features: [], + workspaceCwd: '/w', + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { id: 'w1', cwd: '/wsB', primary: false, trusted: true }, + ], + }; + sessionsState.sessions = [session('a', { displayName: 'Alpha' })]; + otherWorkspaceSessions['/wsB'] = [ + session('b1', { workspaceCwd: '/wsB', displayName: 'Beta' }), + ]; + render(); + await flushAsync(); + // Exclude the primary workspace through the funnel filter. + const trigger = container!.querySelector( + 'button[aria-label="Filter by workspace"]', + ) as HTMLElement; + act(() => click(trigger)); + const main = document.querySelector( + '#session-overview-workspace-0', + ) as HTMLElement; + act(() => click(main)); + expect(rowTitles()).toEqual(['Beta']); + + // /wsB loses trust (capabilities hot-reload): the option set shrinks to + // the lone primary, and the funnel — the only control that manages + // exclusions — renders only for two or more options. The exclusion the + // user can no longer see or change must drop, not hide every row. + connectionState.capabilities = { + features: [], + workspaceCwd: '/w', + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { id: 'w1', cwd: '/wsB', primary: false, trusted: false }, + ], + }; + rerender(); + await flushAsync(); + expect( + container!.querySelector('button[aria-label="Filter by workspace"]'), + ).toBeNull(); + expect(rowTitles()).toEqual(['Alpha']); + }); + + it('shows all available row actions without a menu', () => { + connectionState.capabilities = { + features: ['workspace_session_metadata', 'session_export'], + workspaceCwd: '/w', + }; + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + render(); + const row = rows()[0]!; + expect(rowActionButton(row, 'Rename')).not.toBeNull(); + expect(rowActionButton(row, 'Export conversation record')).not.toBeNull(); + expect(rowActionButton(row, 'Archive')).not.toBeNull(); + expect(rowActionButton(row, 'Delete')).not.toBeNull(); + expect(rowActionButton(row, 'Rename').textContent).toBe(''); + expect(rowActionButton(row, 'Rename').querySelector('svg')).not.toBeNull(); + expect(rowActionButton(row, 'Rename').className).toContain( + 'text-muted-foreground', + ); + expect(rowActionButton(row, 'Rename').className).toContain( + 'hover:text-foreground', + ); + expect(rowActionButton(row, 'Delete').className).toContain( + 'text-destructive', + ); + expect( + rowActionButton(row, 'Export conversation record').querySelector('svg'), + ).not.toBeNull(); + expect(row.querySelector('[data-slot="dropdown-menu-trigger"]')).toBeNull(); + }); + + it('disables archive and delete actions for a running session', () => { + sessionsState.sessions = [ + session('s1', { displayName: 'Running', hasActivePrompt: true }), + ]; + render(); + const row = rows()[0]!; + expect(rowActionButton(row, 'Archive').disabled).toBe(true); + expect(rowActionButton(row, 'Delete').disabled).toBe(true); + + act(() => click(rowCheckbox(row))); + expect(footerButton('Archive')?.disabled).toBe(true); + expect(footerButton('Delete')?.disabled).toBe(true); + }); + + it('labels and pins opaque edge columns without borders', () => { + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + render(); + const headers = Array.from(container!.querySelectorAll('thead th')); + const cells = Array.from(rows()[0]!.querySelectorAll('td')); + const renderedTable = container!.querySelector( + '[data-slot="table"]', + ) as HTMLTableElement; + expect(renderedTable.dataset.layout).toBe('scroll'); + expect(renderedTable.style.minWidth).toBe('912px'); + expect(renderedTable.style.tableLayout).toBe('fixed'); + expect(renderedTable.querySelectorAll('col')).toHaveLength(headers.length); + expect( + (renderedTable.querySelectorAll('col')[1] as HTMLTableColElement).style + .width, + ).toBe('224px'); + expect(headers.at(-1)?.textContent).toContain('Actions'); + expect(headers.at(-1)?.className).toContain('text-center'); + expect((headers.at(-1) as HTMLElement).style.width).toBe('128px'); + expect((cells.at(-1) as HTMLElement).style.width).toBe('128px'); + expect(cells.at(-1)?.firstElementChild?.className).toContain( + 'justify-center', + ); + const timeHeader = headers.find((header) => + header.textContent?.includes('Time'), + ); + const sessionIdHeader = headers.find((header) => + header.textContent?.includes('Session ID'), + ); + const gitHeader = headers.find( + (header) => header.textContent === 'Worktree', + ); + const workspaceHeader = headers.find((header) => + header.textContent?.includes('Workspace'), + ); + const sessionIdColumnIndex = headers.indexOf(sessionIdHeader!); + expect((gitHeader as HTMLElement).style.width).toBe('144px'); + expect(cells[2]?.firstElementChild?.className).toContain('truncate'); + expect((sessionIdHeader as HTMLElement).style.width).toBe('136px'); + expect((cells[sessionIdColumnIndex] as HTMLElement).style.width).toBe( + '136px', + ); + expect((workspaceHeader as HTMLElement).style.width).toBe('128px'); + expect((timeHeader as HTMLElement).style.width).toBe('112px'); + const timeSortButton = timeHeader?.querySelector('button'); + expect(timeSortButton?.className).toContain('px-0'); + expect(timeSortButton?.className).toContain('text-sm'); + expect(timeSortButton?.className).not.toContain('text-xs'); + expect(timeHeader?.getAttribute('aria-sort')).toBeNull(); + act(() => click(timeHeader!.querySelector('button')!)); + expect(timeHeader?.getAttribute('aria-sort')).toBe('ascending'); + expect(headers[0]?.className).toContain('sticky'); + expect((headers[0] as HTMLElement).style.left).toBe('0px'); + expect((headers[0] as HTMLElement).style.width).toBe('40px'); + expect(headers[1]?.className).toContain('sticky'); + expect((headers[1] as HTMLElement).style.left).toBe('40px'); + expect((headers[1] as HTMLElement).style.width).toBe('224px'); + expect(headers.at(-1)?.className).toContain('sticky'); + expect((headers.at(-1) as HTMLElement).style.right).toBe('0px'); + expect(cells[0]?.className).toContain('sticky'); + expect((cells[0] as HTMLElement).style.left).toBe('0px'); + expect((cells[0] as HTMLElement).style.width).toBe('40px'); + expect(cells[1]?.className).toContain('sticky'); + expect((cells[1] as HTMLElement).style.left).toBe('40px'); + expect((cells[1] as HTMLElement).style.width).toBe('224px'); + expect(titleTrigger(rows()[0]!).closest('.truncate')).not.toBeNull(); + expect(titleTrigger(rows()[0]!).className).toContain('text-xs'); + expect(cells[1]?.querySelector('.font-semibold')).not.toBeNull(); + for (const cell of cells.slice(2, 6)) { + expect(cell.querySelector('.text-muted-foreground')).toBeNull(); + expect(cell.querySelector('.text-current')).not.toBeNull(); + } + expect(cells.at(-1)?.className).toContain('sticky'); + expect((cells.at(-1) as HTMLElement).style.right).toBe('0px'); + expect(headers.at(-1)?.className).not.toContain('border-l'); + expect(cells[0]?.className).toContain('bg-background'); + expect(cells[1]?.className).toContain('bg-background'); + expect(cells.at(-1)?.className).toContain('bg-background'); + expect(cells[0]?.className).toContain('transition-colors'); + expect(cells[1]?.className).toContain('transition-colors'); + expect(cells.at(-1)?.className).toContain('transition-colors'); + expect(cells[0]?.className).toContain( + 'group-hover:bg-[color-mix(in_srgb,var(--muted)_50%,var(--background))]', + ); + const footer = container!.querySelector( + '[data-web-shell-session-footer]', + ) as HTMLElement; + expect(footer.className).not.toContain('sticky bottom-0'); + expect(footer.className).not.toContain('border-t'); + }); + + it('distributes flexible columns only when the table fits without scrolling', () => { + const resizeCallbacks = new Set(); + const originalResizeObserver = globalThis.ResizeObserver; + globalThis.ResizeObserver = class { + constructor(callback: ResizeObserverCallback) { + resizeCallbacks.add(callback); + } + observe() {} + unobserve() {} + disconnect() {} + } as typeof ResizeObserver; + try { + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + render(); + const scroller = container!.querySelector( + '[data-slot="table-container"]', + ) as HTMLElement; + Object.defineProperties(scroller, { + clientWidth: { configurable: true, value: 1200 }, + scrollWidth: { configurable: true, value: 1200 }, + }); + act(() => + resizeCallbacks.forEach((callback) => + callback([], {} as ResizeObserver), + ), + ); + const table = container!.querySelector( + '[data-slot="table"]', + ) as HTMLTableElement; + const headers = Array.from(container!.querySelectorAll('thead th')); + expect(table.dataset.layout).toBe('fluid'); + expect(table.style.tableLayout).toBe('fixed'); + expect( + parseFloat((headers[1] as HTMLElement).style.width), + ).toBeGreaterThan(224); + expect((headers[1] as HTMLElement).style.minWidth).toBe('224px'); + expect((headers.at(-1) as HTMLElement).style.width).toBe('128px'); + const columnWidth = Array.from(table.querySelectorAll('col')).reduce( + (total, column) => total + parseFloat(column.style.width), + 0, + ); + expect(columnWidth).toBeCloseTo(1200); + } finally { + globalThis.ResizeObserver = originalResizeObserver; + } + }); + + it('sticks the footer without moving vertical scrolling into the table', () => { + const resizeCallbacks = new Set(); + const observedTargets = new Set(); + const originalResizeObserver = globalThis.ResizeObserver; + globalThis.ResizeObserver = class { + constructor(callback: ResizeObserverCallback) { + resizeCallbacks.add(callback); + } + observe(target: Element) { + observedTargets.add(target); + } + unobserve() {} + disconnect() {} + } as typeof ResizeObserver; + try { + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + render(); + const panel = container!.querySelector( + '[data-web-shell-session-panel]', + ) as HTMLElement; + const viewport = panel.parentElement!; + const footer = container!.querySelector( + '[data-web-shell-session-footer]', + ) as HTMLElement; + const tableViewport = container!.querySelector( + '[data-web-shell-session-table-viewport]', + ) as HTMLElement; + const horizontalScroller = container!.querySelector( + '[data-slot="table-container"]', + ) as HTMLElement; + expect(observedTargets.has(tableViewport)).toBe(true); + Object.defineProperties(panel, { + scrollHeight: { configurable: true, value: 380 }, + }); + Object.defineProperties(tableViewport, { + clientHeight: { configurable: true, value: 200 }, + scrollHeight: { configurable: true, value: 200 }, + }); + Object.defineProperties(horizontalScroller, { + scrollLeft: { configurable: true, writable: true, value: 0 }, + clientWidth: { configurable: true, value: 500 }, + scrollWidth: { configurable: true, value: 900 }, + }); + Object.defineProperties(viewport, { + clientHeight: { configurable: true, value: 400 }, + }); + viewport.style.paddingTop = '16px'; + viewport.style.paddingBottom = '16px'; + act(() => + resizeCallbacks.forEach((callback) => + callback([], {} as ResizeObserver), + ), + ); + expect(footer.className).toContain('sticky bottom-0'); + expect(footer.className).toContain('shadow-[0_16px_0_var(--background)]'); + expect(footer.className).toContain('border-t'); + expect(footer.className).toContain('bg-background'); + expect(footer.className).not.toContain('-mb-4'); + expect(panel.className).not.toContain('panelOverflowing'); + expect(tableViewport.className).not.toContain('tableViewportOverflowing'); + const headers = Array.from(container!.querySelectorAll('thead th')); + expect(headers[1]?.className).not.toContain('after:shadow-'); + expect(headers.at(-1)?.className).toContain( + 'after:shadow-[inset_-10px_0_8px_-8px_var(--border)]', + ); + + Object.defineProperties(panel, { + scrollHeight: { configurable: true, value: 300 }, + }); + act(() => + resizeCallbacks.forEach((callback) => + callback([], {} as ResizeObserver), + ), + ); + expect(footer.className).not.toContain('sticky bottom-0'); + expect(footer.className).not.toContain('border-t'); + } finally { + globalThis.ResizeObserver = originalResizeObserver; + } + }); + + it('holds the sticky footer through its own mode-switch height delta', () => { + const resizeCallbacks = new Set(); + const originalResizeObserver = globalThis.ResizeObserver; + globalThis.ResizeObserver = class { + constructor(callback: ResizeObserverCallback) { + resizeCallbacks.add(callback); + } + observe() {} + unobserve() {} + disconnect() {} + } as typeof ResizeObserver; + try { + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + render(); + const panel = container!.querySelector( + '[data-web-shell-session-panel]', + ) as HTMLElement; + const viewport = panel.parentElement!; + const footer = container!.querySelector( + '[data-web-shell-session-footer]', + ) as HTMLElement; + const tableViewport = container!.querySelector( + '[data-web-shell-session-table-viewport]', + ) as HTMLElement; + Object.defineProperties(tableViewport, { + clientHeight: { configurable: true, value: 200 }, + scrollHeight: { configurable: true, value: 200 }, + }); + Object.defineProperties(viewport, { + clientHeight: { configurable: true, value: 400 }, + }); + // Content is viewport+2 tall in the non-sticky layout: engage. + Object.defineProperties(panel, { + scrollHeight: { configurable: true, value: 402 }, + }); + act(() => + resizeCallbacks.forEach((callback) => + callback([], {} as ResizeObserver), + ), + ); + expect(footer.className).toContain('sticky bottom-0'); + // Sticky mode's own decorations add 13px to the measured natural + // height. The decision must hold through that self-inflicted delta. + Object.defineProperties(panel, { + scrollHeight: { configurable: true, value: 415 }, + }); + act(() => + resizeCallbacks.forEach((callback) => + callback([], {} as ResizeObserver), + ), + ); + expect(footer.className).toContain('sticky bottom-0'); + // Once the content genuinely fits again, release. + Object.defineProperties(panel, { + scrollHeight: { configurable: true, value: 411 }, + }); + act(() => + resizeCallbacks.forEach((callback) => + callback([], {} as ResizeObserver), + ), + ); + expect(footer.className).not.toContain('sticky bottom-0'); + } finally { + globalThis.ResizeObserver = originalResizeObserver; + } + }); + + it('adds inward shadows to fixed columns while horizontally scrolled', () => { + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + render(); + const scrollContainer = container!.querySelector( + '[data-slot="table-container"]', + ) as HTMLElement; + Object.defineProperties(scrollContainer, { + scrollLeft: { configurable: true, value: 100 }, + clientWidth: { configurable: true, value: 500 }, + scrollWidth: { configurable: true, value: 900 }, + }); + act(() => scrollContainer.dispatchEvent(new Event('scroll'))); + const cells = Array.from(rows()[0]!.querySelectorAll('td')); + expect(cells[0]?.className).not.toContain('after:shadow-'); + expect(cells[1]?.className).toContain( + 'after:shadow-[inset_10px_0_8px_-8px_var(--border)]', + ); + expect(cells.at(-1)?.className).toContain( + 'after:shadow-[inset_-10px_0_8px_-8px_var(--border)]', + ); + }); + + it('archives a primary-workspace session from its row action', async () => { + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + render(); + act(() => click(rowActionButton(rows()[0]!, 'Archive'))); + expect(document.body.textContent).toContain('Archive session?'); + expect(workspaceActions.archiveSession).not.toHaveBeenCalled(); + const confirm = document.querySelector( + '[data-slot="alert-dialog-action"]', + ) as HTMLElement; + act(() => click(confirm)); + await flushAsync(); + expect(workspaceClient.archiveSessionsData).toHaveBeenCalledWith(['s1']); + expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledWith( + '/w', + ); + expect(onOpenSession).not.toHaveBeenCalled(); + }); + + it('archives the current session and clears it after success', async () => { + connectionState.sessionId = 's1'; + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + render({ onCurrentSessionRemoved }); + expect(rowActionButton(rows()[0]!, 'Archive').disabled).toBe(false); + expect(rowActionButton(rows()[0]!, 'Delete').disabled).toBe(false); + act(() => click(rowActionButton(rows()[0]!, 'Archive'))); + const confirm = document.querySelector( + '[data-slot="alert-dialog-action"]', + ) as HTMLElement; + act(() => click(confirm)); + await flushAsync(); + expect(workspaceClient.archiveSessionsData).toHaveBeenCalledWith(['s1']); + expect(onCurrentSessionRemoved).toHaveBeenCalledOnce(); + }); + + it('treats an already-missing current session as archived', async () => { + connectionState.sessionId = 's1'; + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + workspaceClient.archiveSessionsData.mockResolvedValueOnce({ + archived: [], + alreadyArchived: [], + notFound: ['s1'], + errors: [], + }); + render({ onCurrentSessionRemoved }); + act(() => click(rowActionButton(rows()[0]!, 'Archive'))); + const confirm = document.querySelector( + '[data-slot="alert-dialog-action"]', + ) as HTMLElement; + act(() => click(confirm)); + await flushAsync(); + + expect(onCurrentSessionRemoved).toHaveBeenCalledOnce(); + expect(container!.textContent).not.toContain('Failed to archive session'); + }); + + it('deletes the current session and clears it after success', async () => { + connectionState.sessionId = 's1'; + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + render({ onCurrentSessionRemoved }); + act(() => click(rowActionButton(rows()[0]!, 'Delete'))); + const confirm = document.querySelector( + '[data-slot="alert-dialog-action"]', + ) as HTMLElement; + act(() => click(confirm)); + await flushAsync(); + expect(workspaceClient.deleteSessionsData).toHaveBeenCalledWith(['s1']); + expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledWith( + '/w', + ); + expect(onCurrentSessionRemoved).toHaveBeenCalledOnce(); + }); + + it.each([ + ['Archive', 'archiveSessionsData', 'Failed to archive session'], + ['Delete', 'deleteSessionsData', 'Failed to delete session'], + ] as const)( + 'does not clear the current session when %s fails', + async (label, action, error) => { + connectionState.sessionId = 's1'; + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + workspaceClient[action].mockRejectedValueOnce(new Error('daemon busy')); + render({ onCurrentSessionRemoved }); + + act(() => click(rowActionButton(rows()[0]!, label))); + const confirm = document.querySelector( + '[data-slot="alert-dialog-action"]', + ) as HTMLElement; + act(() => click(confirm)); + await flushAsync(); + + expect(onCurrentSessionRemoved).not.toHaveBeenCalled(); + expect(container!.textContent).toContain(`${error}: daemon busy`); + // A rejected mutation still refreshes the owning catalog workspace so + // the overview doesn't sit on stale rows. + expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledWith( + '/w', + ); + }, + ); + + it.each([ + [ + 'Archive', + 'archiveSessionsData', + { archived: ['s1'], alreadyArchived: [], notFound: [], errors: [] }, + ], + [ + 'Delete', + 'deleteSessionsData', + { removed: ['s1'], notFound: [], errors: [] }, + ], + ] as const)( + 'does not clear a newly selected session when %s finishes', + async (label, action, result) => { + connectionState.sessionId = 's1'; + sessionsState.sessions = [ + session('s1', { displayName: 'One' }), + session('s2', { displayName: 'Two' }), + ]; + let resolveMutation!: (value: typeof result) => void; + workspaceClient[action].mockReturnValueOnce( + new Promise((resolve) => { + resolveMutation = resolve; + }), + ); + render({ onCurrentSessionRemoved }); + + const one = rows().find((row) => row.textContent?.includes('One'))!; + act(() => click(rowActionButton(one, label))); + const confirm = document.querySelector( + '[data-slot="alert-dialog-action"]', + ) as HTMLElement; + act(() => click(confirm)); + expect(workspaceClient[action]).toHaveBeenCalledWith(['s1']); + + connectionState.sessionId = 's2'; + rerender({ onCurrentSessionRemoved }); + await act(async () => resolveMutation(result)); + await flushAsync(); + + expect(onCurrentSessionRemoved).not.toHaveBeenCalled(); + }, + ); + + it('disables unsupported archive and qualified workspace mutations', async () => { + connectionState.capabilities = { + features: ['session_archive'], + workspaceCwd: '/w', + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { id: 'w1', cwd: '/wsB', primary: false, trusted: true }, + ], + }; + sessionsState.sessions = [session('a', { displayName: 'Alpha' })]; + otherWorkspaceSessions['/wsB'] = [ + session('b1', { workspaceCwd: '/wsB', displayName: 'Beta' }), + ]; + render(); + await flushAsync(); + const alpha = rows().find((row) => row.textContent?.includes('Alpha'))!; + const beta = rows().find((row) => row.textContent?.includes('Beta'))!; + expect(rowActionButton(alpha, 'Archive').disabled).toBe(false); + expect(rowActionButton(beta, 'Archive').disabled).toBe(true); + expect(rowActionButton(beta, 'Delete').disabled).toBe(true); + + connectionState.capabilities = { + ...connectionState.capabilities, + features: ['workspace_qualified_rest_core'], + }; + rerender(); + expect(rowActionButton(rows()[0]!, 'Archive').disabled).toBe(true); + }); + + it('rejects mutations for unknown and untrusted workspace scopes', () => { + connectionState.sessionId = 'untrusted'; + connectionState.capabilities = { + features: [ + 'session_archive', + 'workspace_qualified_rest_core', + 'workspace_session_metadata', + 'workspace_session_export', + ], + workspaceCwd: '/w', + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { id: 'w1', cwd: '/untrusted', primary: false, trusted: false }, + ], + }; + sessionsState.sessions = [ + session('unknown', { + workspaceCwd: '/unknown', + displayName: 'Unknown', + }), + session('untrusted', { + workspaceCwd: '/untrusted', + displayName: 'Untrusted', + }), + ]; + render(); + + for (const title of ['Unknown', 'Untrusted']) { + const row = rows().find((item) => item.textContent?.includes(title))!; + for (const action of [ + 'Rename', + 'Export conversation record', + 'Archive', + 'Delete', + ]) { + expect(rowActionButton(row, action).disabled).toBe(true); + } + } + }); + + it('routes archive for an other-workspace session through the qualified client', async () => { + connectionState.capabilities = { + features: ['session_archive', 'workspace_qualified_rest_core'], + workspaceCwd: '/w', + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { id: 'w1', cwd: '/wsB', primary: false, trusted: true }, + ], + }; + sessionsState.sessions = [session('a', { displayName: 'Alpha' })]; + otherWorkspaceSessions['/wsB'] = [ + session('b1', { workspaceCwd: '/wsB', displayName: 'Beta' }), + ]; + render(); + await flushAsync(); + const beta = rows().find((tr) => tr.textContent?.includes('Beta'))!; + act(() => click(rowActionButton(beta, 'Archive'))); + const confirm = document.querySelector( + '[data-slot="alert-dialog-action"]', + ) as HTMLElement; + act(() => click(confirm)); + await flushAsync(); + expect(workspaceClient.workspaceByCwd).toHaveBeenCalledWith('/wsB'); + // The mutation used the client returned by workspaceByCwd; grab that same + // instance from the mock results (each call builds a fresh object). + const qualified = workspaceClient.workspaceByCwd.mock.results.at(-1)!.value; + expect(qualified.archiveSessionsData).toHaveBeenCalledWith(['b1']); + expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledWith( + '/wsB', + ); + }); + + it('deletes a session through the confirm dialog', async () => { + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + render(); + act(() => click(rowActionButton(rows()[0]!, 'Delete'))); + // The destructive confirm dialog is shown with the session name. + expect(document.body.textContent).toContain('Delete session?'); + const confirm = document.querySelector( + '[data-slot="alert-dialog-action"]', + ) as HTMLElement; + act(() => click(confirm)); + await flushAsync(); + expect(workspaceClient.deleteSessionsData).toHaveBeenCalledWith(['s1']); + }); + + it('batch-archives the selected sessions', async () => { + workspaceClient.archiveSessionsData.mockResolvedValueOnce({ + archived: ['a'], + alreadyArchived: [], + notFound: ['b'], + errors: [], + }); + sessionsState.sessions = [ + session('a', { displayName: 'A' }), + session('b', { displayName: 'B' }), + ]; + render(); + act(() => click(selectAllCheckbox())); + act(() => click(footerButton('Archive')!)); + expect(document.body.textContent).toContain('Archive 2 sessions?'); + const confirm = document.querySelector( + '[data-slot="alert-dialog-action"]', + ) as HTMLElement; + act(() => click(confirm)); + await flushAsync(); + expect(workspaceClient.archiveSessionsData).toHaveBeenCalledOnce(); + expect(workspaceClient.archiveSessionsData).toHaveBeenCalledWith([ + 'a', + 'b', + ]); + expect(container!.textContent).not.toContain('Failed to archive session'); + }); + + it('disables row actions while a batch mutation is running', async () => { + connectionState.sessionId = 's1'; + connectionState.capabilities = { + features: ['session_archive', 'session_export'], + workspaceCwd: '/w', + }; + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + let resolveArchive!: (value: { + archived: string[]; + alreadyArchived: string[]; + notFound: string[]; + errors: []; + }) => void; + workspaceClient.archiveSessionsData.mockReturnValueOnce( + new Promise((resolve) => { + resolveArchive = resolve; + }), + ); + let resolveReload!: (value: DaemonSessionSummary[]) => void; + sessionsReload.mockReturnValueOnce( + new Promise((resolve) => { + resolveReload = resolve; + }), + ); + render(); + act(() => click(rowCheckbox(rows()[0]!))); + act(() => click(footerButton('Archive')!)); + const confirm = document.querySelector( + '[data-slot="alert-dialog-action"]', + ) as HTMLElement; + act(() => click(confirm)); + + expect(rowActionButton(rows()[0]!, 'Rename').disabled).toBe(true); + expect( + rowActionButton(rows()[0]!, 'Export conversation record').disabled, + ).toBe(true); + + await act(async () => + resolveArchive({ + archived: ['s1'], + alreadyArchived: [], + notFound: [], + errors: [], + }), + ); + await flushAsync(); + expect(rowActionButton(rows()[0]!, 'Rename').disabled).toBe(true); + await act(async () => resolveReload(sessionsState.sessions)); + await flushAsync(); + expect(rowActionButton(rows()[0]!, 'Rename').disabled).toBe(false); + expect( + rowActionButton(rows()[0]!, 'Export conversation record').disabled, + ).toBe(false); + }); + + it('batch-deletes the selected sessions after confirmation', async () => { + workspaceClient.deleteSessionsData.mockResolvedValueOnce({ + removed: ['a'], + notFound: ['b'], + errors: [], + }); + sessionsState.sessions = [ + session('a', { displayName: 'A' }), + session('b', { displayName: 'B' }), + ]; + render(); + act(() => click(selectAllCheckbox())); + act(() => click(footerButton('Delete')!)); + // The bulk confirm dialog names the count. + expect(document.body.textContent).toContain('Delete 2 sessions?'); + const confirm = document.querySelector( + '[data-slot="alert-dialog-action"]', + ) as HTMLElement; + act(() => click(confirm)); + await flushAsync(); + expect(workspaceClient.deleteSessionsData).toHaveBeenCalledOnce(); + expect(workspaceClient.deleteSessionsData).toHaveBeenCalledWith(['a', 'b']); + expect(container!.textContent).not.toContain('Failed to delete session'); + }); + + it('clears a deleted current session after a partial batch failure', async () => { + connectionState.sessionId = 'current'; + connectionState.capabilities = { + features: ['session_archive', 'workspace_qualified_rest_core'], + workspaceCwd: '/w', + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { id: 'w1', cwd: '/wsB', primary: false, trusted: true }, + ], + }; + sessionsState.sessions = [ + session('current', { + displayName: 'Current', + updatedAt: '2026-01-02T00:00:00.000Z', + }), + ]; + otherWorkspaceSessions['/wsB'] = [ + session('secondary', { + workspaceCwd: '/wsB', + displayName: 'Secondary', + updatedAt: '2026-01-01T00:00:00.000Z', + }), + ]; + const qualified = workspaceClient.workspaceByCwd('/wsB'); + qualified.deleteSessionsData.mockResolvedValue({ + removed: [], + notFound: [], + errors: [{ sessionId: 'secondary', error: 'locked' }], + }); + workspaceClient.workspaceByCwd.mockReturnValue(qualified); + + render({ onCurrentSessionRemoved }); + await flushAsync(); + act(() => click(selectAllCheckbox())); + act(() => click(footerButton('Delete')!)); + const confirm = document.querySelector( + '[data-slot="alert-dialog-action"]', + ) as HTMLElement; + sessionsReload.mockClear(); + act(() => click(confirm)); + await flushAsync(); + + expect(workspaceClient.deleteSessionsData).toHaveBeenCalledWith([ + 'current', + ]); + expect(onCurrentSessionRemoved).toHaveBeenCalledOnce(); + expect(sessionsReload).toHaveBeenCalled(); + expect(container!.textContent).toContain( + 'Failed to delete session: locked', + ); + expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledTimes(2); + expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledWith( + '/w', + ); + expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledWith( + '/wsB', + ); + }); + + it('surfaces a failure to clear the deleted current session', async () => { + connectionState.sessionId = 's1'; + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + onCurrentSessionRemoved.mockResolvedValue(false); + render({ onCurrentSessionRemoved }); + act(() => click(rowActionButton(rows()[0]!, 'Delete'))); + const confirm = document.querySelector( + '[data-slot="alert-dialog-action"]', + ) as HTMLElement; + act(() => click(confirm)); + await flushAsync(); + expect(container!.textContent).toContain('Failed to create a new chat'); + expect(container!.textContent).not.toContain('Failed to delete session'); + }); + + it('surfaces a failure to clear the archived current session', async () => { + connectionState.sessionId = 's1'; + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + onCurrentSessionRemoved.mockResolvedValue(false); + render({ onCurrentSessionRemoved }); + act(() => click(rowActionButton(rows()[0]!, 'Archive'))); + const confirm = document.querySelector( + '[data-slot="alert-dialog-action"]', + ) as HTMLElement; + act(() => click(confirm)); + await flushAsync(); + expect(container!.textContent).toContain('Failed to create a new chat'); + expect(container!.textContent).not.toContain('Failed to archive session'); + }); + + it('renames the current session inline from its row action', async () => { + connectionState.sessionId = 's1'; + connectionState.capabilities = { + features: [], + workspaceCwd: '/w', + }; + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + let resolveReload!: (value: DaemonSessionSummary[]) => void; + sessionsReload.mockReturnValueOnce( + new Promise((resolve) => { + resolveReload = resolve; + }), + ); + render(); + act(() => click(rowActionButton(rows()[0]!, 'Rename'))); + const input = container!.querySelector( + 'input[aria-label="Rename: One"]', + ) as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.closest('.truncate')).toBeNull(); + act(() => setInputValue(input, 'Renamed')); + const form = input.closest('form')!; + act(() => + form.dispatchEvent( + new Event('submit', { bubbles: true, cancelable: true }), + ), + ); + await flushAsync(); + // The current session renames through its own session actions. + expect(workspaceActions.renameSession).toHaveBeenCalledWith('Renamed'); + expect(sessionCatalogController.renamed).toHaveBeenCalledWith( + '/w', + 's1', + 'Renamed', + ); + expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledWith( + '/w', + ); + expect(rowActionButton(rows()[0]!, 'Rename').disabled).toBe(true); + await act(async () => resolveReload(sessionsState.sessions)); + await flushAsync(); + expect(rowActionButton(rows()[0]!, 'Rename').disabled).toBe(false); + }); + + it('falls back to the typed name when a rename resolves no metadata', async () => { + connectionState.sessionId = 's1'; + connectionState.capabilities = { + features: ['workspace_session_metadata'], + workspaceCwd: '/w', + }; + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + workspaceActions.renameSession.mockResolvedValueOnce(undefined); + render(); + act(() => click(rowActionButton(rows()[0]!, 'Rename'))); + const input = container!.querySelector( + 'input[aria-label="Rename: One"]', + ) as HTMLInputElement; + act(() => setInputValue(input, 'Void Name')); + const form = input.closest('form')!; + act(() => + form.dispatchEvent( + new Event('submit', { bubbles: true, cancelable: true }), + ), + ); + await flushAsync(); + expect(sessionCatalogController.renamed).toHaveBeenCalledWith( + '/w', + 's1', + 'Void Name', + ); + }); + + it('renames the current trusted secondary session from the global overview', async () => { + connectionState.sessionId = 'b1'; + connectionState.workspaceCwd = '/wsB'; + connectionState.capabilities = { + features: [], + workspaceCwd: '/w', + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { id: 'w1', cwd: '/wsB', primary: false, trusted: true }, + ], + }; + sessionsState.sessions = [session('a', { displayName: 'Alpha' })]; otherWorkspaceSessions['/wsB'] = [ session('b1', { workspaceCwd: '/wsB', displayName: 'Beta' }), ]; render(); - await flushAsync(); // let the other-workspace fan-out resolve - // The non-primary session shows up as its own card… - expect(cardLabels()).toContain('Beta'); - // …tagged with its workspace display name… - expect(container!.textContent).toContain('Payments API'); - // …while the primary card no longer carries a localized "Primary" word - // (it now shows its own basename, like every other card). - expect(container!.textContent).not.toContain('Primary'); + await flushAsync(); + + const beta = rows().find((row) => row.textContent?.includes('Beta'))!; + const rename = rowActionButton(beta, 'Rename'); + expect(rename.disabled).toBe(false); + act(() => click(rename)); + const input = container!.querySelector( + 'input[aria-label="Rename: Beta"]', + ) as HTMLInputElement; + act(() => setInputValue(input, 'Renamed Beta')); + act(() => + input + .closest('form')! + .dispatchEvent( + new Event('submit', { bubbles: true, cancelable: true }), + ), + ); + await flushAsync(); + + expect(workspaceActions.renameSession).toHaveBeenCalledWith('Renamed Beta'); }); - it('does not query other workspaces on a single-workspace daemon', async () => { - sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })]; + it('renames the current session in a locked legacy secondary workspace', async () => { + connectionState.sessionId = 'b1'; + connectionState.workspaceCwd = ''; + connectionState.capabilities = { + features: [], + workspaceCwd: '/w', + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { id: 'w1', cwd: '/wsB', primary: false, trusted: true }, + ], + }; + sessionsState.sessions = [ + session('b1', { workspaceCwd: '/wsB', displayName: 'Beta' }), + ]; + render({ workspaceCwd: '/wsB' }); + + const rename = rowActionButton(rows()[0]!, 'Rename'); + expect(rename.disabled).toBe(false); + act(() => click(rename)); + const input = container!.querySelector( + 'input[aria-label="Rename: Beta"]', + ) as HTMLInputElement; + act(() => setInputValue(input, 'Renamed Beta')); + act(() => + input + .closest('form')! + .dispatchEvent( + new Event('submit', { bubbles: true, cancelable: true }), + ), + ); + await flushAsync(); + + expect(workspaceActions.renameSession).toHaveBeenCalledWith('Renamed Beta'); + }); + + it('does not rename a new current session from a stale editor', async () => { + connectionState.sessionId = 's1'; + connectionState.capabilities = { features: [], workspaceCwd: '/w' }; + sessionsState.sessions = [ + session('s1', { displayName: 'One' }), + session('s2', { displayName: 'Two' }), + ]; render(); + const one = rows().find((row) => row.textContent?.includes('One'))!; + act(() => click(rowActionButton(one, 'Rename'))); + const input = container!.querySelector( + 'input[aria-label="Rename: One"]', + ) as HTMLInputElement; + act(() => setInputValue(input, 'Renamed One')); + + connectionState.sessionId = 's2'; + rerender(); + act(() => + input + .closest('form')! + .dispatchEvent( + new Event('submit', { bubbles: true, cancelable: true }), + ), + ); await flushAsync(); - expect(workspaceClient.listWorkspaceSessionsPage).not.toHaveBeenCalled(); - // No workspace badge on a single-workspace daemon. - expect(container!.textContent).not.toContain('wsB'); + + expect(workspaceActions.renameSession).not.toHaveBeenCalled(); + }); + + it('renames an other-workspace session through the qualified client', async () => { + connectionState.capabilities = { + features: ['workspace_session_metadata', 'workspace_qualified_rest_core'], + workspaceCwd: '/w', + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { id: 'w1', cwd: '/wsB', primary: false, trusted: true }, + ], + }; + sessionsState.sessions = [session('a', { displayName: 'Alpha' })]; + otherWorkspaceSessions['/wsB'] = [ + session('b1', { workspaceCwd: '/wsB', displayName: 'Beta' }), + ]; + render(); + await flushAsync(); + const beta = rows().find((tr) => tr.textContent?.includes('Beta'))!; + act(() => click(rowActionButton(beta, 'Rename'))); + const input = container!.querySelector( + 'input[aria-label="Rename: Beta"]', + ) as HTMLInputElement; + act(() => setInputValue(input, 'Renamed Beta')); + const form = input.closest('form')!; + act(() => + form.dispatchEvent( + new Event('submit', { bubbles: true, cancelable: true }), + ), + ); + await flushAsync(); + const qualified = workspaceClient.workspaceByCwd.mock.results.at(-1)!.value; + expect(qualified.updateSessionMetadata).toHaveBeenCalledWith('b1', { + displayName: 'Renamed Beta', + }); + expect(sessionCatalogController.renamed).toHaveBeenCalledWith( + '/wsB', + 'b1', + 'Renamed', + ); + expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledWith( + '/wsB', + ); + }); + + it('exports a session as an html download', async () => { + connectionState.capabilities = { + features: ['session_export'], + workspaceCwd: '/w', + }; + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + render(); + act(() => click(rowActionButton(rows()[0]!, 'Export conversation record'))); + await flushAsync(); + expect(workspaceActions.exportSession).toHaveBeenCalledWith('s1', 'html'); + expect(URL.createObjectURL).toHaveBeenCalled(); + expect(anchorClick).toHaveBeenCalled(); + }); + + it('exports a trusted other-workspace session when supported', async () => { + connectionState.capabilities = { + features: ['workspace_session_export'], + workspaceCwd: '/w', + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { id: 'w1', cwd: '/wsB', primary: false, trusted: true }, + ], + }; + sessionsState.sessions = [session('a', { displayName: 'Alpha' })]; + otherWorkspaceSessions['/wsB'] = [ + session('b1', { workspaceCwd: '/wsB', displayName: 'Beta' }), + ]; + render(); + await flushAsync(); + const alpha = rows().find((row) => row.textContent?.includes('Alpha'))!; + const beta = rows().find((row) => row.textContent?.includes('Beta'))!; + expect(rowActionButton(alpha, 'Export conversation record').disabled).toBe( + true, + ); + const exportBeta = rowActionButton(beta, 'Export conversation record'); + expect(exportBeta.disabled).toBe(false); + act(() => click(exportBeta)); + await flushAsync(); + const qualified = workspaceClient.workspaceByCwd.mock.results.at(-1)!.value; + expect(qualified.exportSession).toHaveBeenCalledWith('b1', { + format: 'html', + }); + }); + + it('surfaces an archive failure in the notice area', async () => { + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + workspaceClient.archiveSessionsData.mockRejectedValueOnce( + new Error('daemon busy'), + ); + render(); + act(() => click(rowActionButton(rows()[0]!, 'Archive'))); + const confirm = document.querySelector( + '[data-slot="alert-dialog-action"]', + ) as HTMLElement; + act(() => click(confirm)); + await flushAsync(); + expect(container!.textContent).toContain('Failed to archive session'); + expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledWith( + '/w', + ); + + workspaceClient.archiveSessionsData.mockResolvedValueOnce({ + archived: ['s1'], + alreadyArchived: [], + notFound: [], + errors: [], + }); + act(() => click(rowActionButton(rows()[0]!, 'Archive'))); + const retryConfirm = document.querySelector( + '[data-slot="alert-dialog-action"]', + ) as HTMLElement; + act(() => click(retryConfirm)); + await flushAsync(); + expect(container!.textContent).not.toContain('Failed to archive session'); + }); + + it('surfaces a rename failure in the notice area', async () => { + connectionState.sessionId = 's1'; + connectionState.capabilities = { + features: ['workspace_session_metadata'], + workspaceCwd: '/w', + }; + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + workspaceActions.renameSession.mockRejectedValue(new Error('locked')); + render(); + act(() => click(rowActionButton(rows()[0]!, 'Rename'))); + const input = container!.querySelector( + 'input[aria-label="Rename: One"]', + ) as HTMLInputElement; + act(() => setInputValue(input, 'Renamed')); + const form = input.closest('form')!; + act(() => + form.dispatchEvent( + new Event('submit', { bubbles: true, cancelable: true }), + ), + ); + await flushAsync(); + expect(container!.textContent).toContain('Failed to rename session'); + expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledWith( + '/w', + ); + // The rejected name must not overwrite the catalog's cached display name. + expect(sessionCatalogController.renamed).not.toHaveBeenCalled(); }); }); describe('SessionOverviewPanel polling', () => { - it('polls the session list on an interval', async () => { + it('keeps the old status-report details fresh', async () => { + sessionsState.sessions = [session('s')]; + vi.useFakeTimers(); + try { + render(); + statusReload.mockClear(); + await vi.advanceTimersByTimeAsync(3100); + expect(statusReload).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(7000); + expect(statusReload).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it('polls the session list on an interval when live-state is unavailable', async () => { sessionsState.sessions = [session('s')]; vi.useFakeTimers(); try { @@ -564,6 +2580,111 @@ describe('SessionOverviewPanel polling', () => { } }); + it('skips the catalog poll for a single-workspace live-state daemon', () => { + connectionState.capabilities = { + features: ['workspace_session_live_state'], + workspaceCwd: '/w', + }; + sessionsState.sessions = [session('s')]; + render(); + expect(scopedSessionsOptions.pollIntervalMs).toBeUndefined(); + expect(statusReportOptions).toEqual({ autoLoad: false, detail: 'full' }); + }); + + it('does not poll full status when live-state is available', async () => { + connectionState.capabilities = { + features: ['workspace_session_live_state'], + workspaceCwd: '/w', + }; + sessionsState.sessions = [session('s')]; + vi.useFakeTimers(); + try { + render(); + statusReload.mockClear(); + await vi.advanceTimersByTimeAsync(20000); + expect(statusReload).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('uses sidebar-owned live state without starting a duplicate channel', () => { + connectionState.capabilities = { + features: ['workspace_session_live_state'], + workspaceCwd: '/w', + }; + sessionsState.sessions = [session('s')]; + render({ manageLiveState: false }); + expect(workspaceLiveStateOptions.enabled).toBe(false); + // No duplicate live-state channel — but the panel keeps its own catalog + // poll, because the sidebar's channel only covers trusted workspaces and + // must not freeze the rest of the overview. + expect(scopedSessionsOptions.pollIntervalMs).toBe(3000); + expect(statusReportOptions).toEqual({ autoLoad: true, detail: 'full' }); + }); + + it('keeps its own catalog poll when it does not own the live-state channel', () => { + // Sidebar enabled (manageLiveState=false) and the primary workspace is + // not trusted: the sidebar's trusted-only channel cannot cover it, so the + // panel must fall back to its own polling instead of freezing. + connectionState.capabilities = { + features: ['workspace_session_live_state'], + workspaceCwd: '/w', + workspaces: [{ id: 'w0', cwd: '/w', primary: true, trusted: false }], + }; + sessionsState.sessions = [session('s')]; + render({ manageLiveState: false }); + expect(workspaceLiveStateOptions.enabled).toBe(false); + expect(scopedSessionsOptions.pollIntervalMs).toBe(3000); + expect(statusReportOptions).toEqual({ autoLoad: true, detail: 'full' }); + }); + + it('keeps polling when live state cannot cover an untrusted primary', () => { + connectionState.capabilities = { + features: ['workspace_session_live_state'], + workspaceCwd: '/w', + workspaces: [{ id: 'w0', cwd: '/w', primary: true, trusted: false }], + }; + sessionsState.sessions = [session('s')]; + render(); + expect(workspaceLiveStateOptions.enabled).toBe(false); + expect(scopedSessionsOptions.pollIntervalMs).toBe(3000); + expect(statusReportOptions).toEqual({ autoLoad: true, detail: 'full' }); + }); + + it('subscribes live state for every visible workspace', async () => { + connectionState.capabilities = { + features: ['workspace_session_live_state'], + workspaceCwd: '/w', + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { id: 'w1', cwd: '/wsB', primary: false, trusted: true }, + ], + }; + render(); + await flushAsync(); + expect(workspaceLiveStateOptions.workspaceCwds).toEqual(['/w', '/wsB']); + }); + + it('subscribes live state only for a locked workspace', () => { + connectionState.capabilities = { + features: ['workspace_session_live_state'], + workspaceCwd: '/w', + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { id: 'w1', cwd: '/wsB', primary: false, trusted: true }, + ], + }; + render({ workspaceCwd: '/wsB' }); + expect(workspaceLiveStateOptions.workspaceCwds).toEqual(['/wsB']); + }); + + it('falls back to the catalog poll without live-state', () => { + sessionsState.sessions = [session('s')]; + render(); + expect(scopedSessionsOptions.pollIntervalMs).toBe(3000); + }); + it('skips polling while the tab is hidden', async () => { sessionsState.sessions = [session('s')]; Object.defineProperty(document, 'hidden', { @@ -602,23 +2723,6 @@ describe('SessionOverviewPanel polling', () => { } }); - it('polls the richer status report on its own slower interval', async () => { - sessionsState.sessions = [session('s')]; - vi.useFakeTimers(); - try { - render(); - statusReload.mockClear(); - // One list tick (3s) must NOT drive a status refresh… - await vi.advanceTimersByTimeAsync(3100); - expect(statusReload).not.toHaveBeenCalled(); - // …but crossing the 10s status cadence does, exactly once. - await vi.advanceTimersByTimeAsync(7000); - expect(statusReload).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - it('re-queries other workspaces on each list poll (multi-workspace)', async () => { connectionState.capabilities = { features: [], @@ -633,9 +2737,13 @@ describe('SessionOverviewPanel polling', () => { vi.useFakeTimers(); try { render(); - await vi.advanceTimersByTimeAsync(10); // settle the initial fan-out + await act(async () => { + await vi.advanceTimersByTimeAsync(10); // settle the initial fan-out + }); workspaceClient.listWorkspaceSessionsPage.mockClear(); - await vi.advanceTimersByTimeAsync(3100); // one list-poll tick + await act(async () => { + await vi.advanceTimersByTimeAsync(3100); // one list-poll tick + }); 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 dfa284e5e0b..ee60abb8ad5 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.tsx @@ -6,114 +6,276 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + useActions, useConnection, useStatusReport, + useWorkspace, } from '@qwen-code/webui/daemon-react-sdk'; import type { DaemonSessionGroupPresetColor, DaemonSessionPrInfo, DaemonSessionSummary, DaemonStatusReportSession, + SessionMetadataResult, } from '@qwen-code/sdk/daemon'; +import { + ArchiveIcon, + ArrowUpDownIcon, + CheckIcon, + CopyIcon, + DownloadIcon, + FunnelIcon, + PenLineIcon, + RefreshCwIcon, + SearchIcon, + Trash2Icon, +} from 'lucide-react'; +import { + type ColumnDef, + type PaginationState, + type RowSelectionState, + type SortingState, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; import { useI18n } from '../i18n'; import { SessionPrBadge } from './SessionPrBadge'; import { formatRelativeTime } from '../utils/formatRelativeTime'; -import { buildSplitUrl, MAX_SPLIT_PANES } from '../utils/splitUrl'; import { - hasMultipleWorkspaces, - isNonPrimaryWorkspaceSession, - mergeSessionsById, - workspaceLabelForCwd, -} from '../utils/workspace'; + warnClipboardWriteFailure, + writeClipboardText, +} from '../utils/clipboard'; +import { buildSplitUrl, MAX_SPLIT_PANES } from '../utils/splitUrl'; +import { isExternalOpenUrl } from '../utils/externalOpen'; +import { workspaceLabel, workspaceLabelForCwd } from '../utils/workspace'; import { useOtherWorkspaceSessions } from '../hooks/useOtherWorkspaceSessions'; import { useScopedSessions } from '../hooks/useScopedSessions'; +import { useWorkspaceSessionLiveState } from '../session-catalog/workspace-session-live-state'; +import { useSessionCatalogController } from '../session-catalog/session-catalog-hooks'; import { getDaemonToken } from '../config/daemon'; import { SESSION_LIST_PAGE_SIZE, + SESSION_LIVE_STATE_FEATURE, SESSION_ORGANIZATION_FEATURE, } from '../constants/sessions'; import { ErrorBoundary } from './ErrorBoundary'; -import { getModelDisplayName } from '../utils/modelDisplay'; +import { SessionDetailsTooltip } from './sidebar/SessionDetailsTooltip'; +import { Button } from './ui/button'; +import { Badge } from './ui/badge'; +import { Checkbox } from './ui/checkbox'; +import { Input } from './ui/input'; +import { Spinner } from './ui/spinner'; +import { Label } from './ui/label'; +import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; +import { + DataTable, + DataTablePagination, + type DataTableColumnMeta, +} from './ui/data-table'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from './ui/tooltip'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from './ui/alert-dialog'; import styles from './SessionOverviewPanel.module.css'; -// The list is cheap to poll (it's the same endpoint the sidebar already hits), -// so it drives the primary running/idle liveness at a snappy cadence. The -// detail=full status report is materially more expensive — it aggregates -// per-session diagnostics and can spawn the ACP child — so DaemonStatusDialog -// deliberately never polls it. We do poll it here, but slowly: it is the only -// source of the per-session "needs approval" signal, which is the whole point -// of a mission-control view, and a bounded 10s cadence keeps the cost in check -// while approval badges stay live-enough. Both polls pause when the tab is -// hidden or a previous request is still outstanding. +// The daemon's live-state channel (2s, coordinated through the shared session +// catalog) is the primary refresh path when advertised; the full-list catalog +// poll is only the fallback for daemons without the feature. Full status fans +// out expensive diagnostics, so poll it less often, pause while hidden, and +// never overlap requests. const LIST_POLL_MS = 3000; const STATUS_POLL_MS = 10000; +const PAGE_SIZE = 50; +const PAGE_SIZES = [10, 50, 100] as const; +const PAGE_SIZE_STORAGE_KEY = 'qwen-web-shell-session-overview-page-size'; + +function SessionIdCell({ sessionId }: { sessionId: string }) { + const { t } = useI18n(); + const [copied, setCopied] = useState(false); + const resetTimerRef = useRef(undefined); + + useEffect(() => () => window.clearTimeout(resetTimerRef.current), []); + + return ( +
+ + + + {sessionId} + + + {sessionId} + + + + + + + {copied ? t('sidebar.sessionIdCopied') : t('sidebar.copySessionId')} + + + + {copied ? t('sidebar.sessionIdCopied') : ''} + +
+ ); +} + +function readPageSize(): number { + if (typeof window === 'undefined') return PAGE_SIZE; + try { + const stored = Number(window.localStorage.getItem(PAGE_SIZE_STORAGE_KEY)); + return stored === 10 || stored === 50 || stored === 100 + ? stored + : PAGE_SIZE; + } catch { + return PAGE_SIZE; + } +} + +function writePageSize(pageSize: number): void { + try { + window.localStorage.setItem(PAGE_SIZE_STORAGE_KEY, String(pageSize)); + } catch { + // localStorage can be unavailable in private or embedded contexts. + } +} -export type SessionCardStatus = 'needsApproval' | 'running' | 'idle'; +export type SessionCardStatus = + | 'needsApproval' + | 'askUserQuestion' + | 'running' + | 'idle'; export interface SessionCard { sessionId: string; label: string; status: SessionCardStatus; - clientCount: number; - model?: string; updatedAt?: string; color?: DaemonSessionGroupPresetColor | null; isCurrent: boolean; /** GitHub PRs bound to the session, in binding order (last = latest). */ prs?: DaemonSessionPrInfo[]; + gitBranch?: string; /** The workspace the session lives in. */ workspaceCwd: string; - /** True when the session belongs to a non-primary workspace. */ - isNonPrimary: boolean; +} + +type SessionIdentity = Pick; + +function getSessionIdentity(session: SessionIdentity): string { + return `${session.workspaceCwd}\0${session.sessionId}`; +} + +function isCurrentSession( + session: SessionIdentity, + currentSessionId: string | undefined, + currentWorkspaceCwd: string | undefined, +): boolean { + return ( + session.sessionId === currentSessionId && + (!currentWorkspaceCwd || session.workspaceCwd === currentWorkspaceCwd) + ); } const STATUS_PRIORITY: Record = { needsApproval: 0, - running: 1, - idle: 2, + askUserQuestion: 1, + running: 2, + idle: 3, }; /** - * Merge the (cheap, all-sessions) list with the (richer, loaded-sessions-only) - * status report into one ranked set of cards. `needsApproval` is derived from - * the status report's `pendingPermissionCount` and takes precedence over - * `running` because it is the actionable state — the session is blocked waiting - * for the user. Cold sessions absent from the status report simply read as - * idle. Sorted needs-approval → running → idle, then most-recent first, so the - * sessions that want attention float to the top of a 10+ session grid. + * Derive the ranked card set from the session list. The volatile live state + * (`hasActivePrompt`, `isWaitingForPermission`, `isWaitingForUserQuestion`) + * arrives on the summaries themselves — merged in by the shared session + * catalog's live-state channel when the daemon advertises it, and carried on + * plain list responses otherwise. `needsApproval` and `askUserQuestion` are + * the actionable states (the session is blocked waiting for the user) and take + * precedence over `running`. Sorted needs-approval → question → running → + * idle, then most-recent first, so sessions that want attention float to the + * top of a 10+ session grid. */ export function deriveSessionCards( sessions: DaemonSessionSummary[], - statusSessions: DaemonStatusReportSession[], currentSessionId: string | undefined, - primaryCwd?: string, + statusSessions: DaemonStatusReportSession[] = [], + currentWorkspaceCwd?: string, ): SessionCard[] { - const statusById = new Map( - statusSessions.map((session) => [session.sessionId, session]), + const statusByIdentity = new Map( + statusSessions.map((session) => [getSessionIdentity(session), session]), ); const cards = sessions.map((session): SessionCard => { - const status = statusById.get(session.sessionId); - const running = session.hasActivePrompt ?? status?.hasActivePrompt ?? false; - const needsApproval = (status?.pendingPermissionCount ?? 0) > 0; + const status = statusByIdentity.get(getSessionIdentity(session)); + const needsApproval = + session.isWaitingForPermission === true || + (status?.pendingPermissionCount ?? 0) > 0; + const askUserQuestion = + !needsApproval && session.isWaitingForUserQuestion === true; return { sessionId: session.sessionId, label: session.displayName?.trim() || session.sessionId.slice(0, 8), - status: needsApproval ? 'needsApproval' : running ? 'running' : 'idle', - clientCount: session.clientCount ?? status?.clientCount ?? 0, - model: status?.currentModelId?.startsWith('qwen-route:') - ? undefined - : status?.currentModelId - ? getModelDisplayName(status.currentModelId) - : undefined, + status: needsApproval + ? 'needsApproval' + : askUserQuestion + ? 'askUserQuestion' + : (session.hasActivePrompt ?? status?.hasActivePrompt) + ? 'running' + : 'idle', updatedAt: session.updatedAt || session.createdAt, color: session.color, - isCurrent: session.sessionId === currentSessionId, + isCurrent: isCurrentSession( + session, + currentSessionId, + currentWorkspaceCwd, + ), prs: session.prs, + gitBranch: session.worktree?.branch ?? session.branch?.name, workspaceCwd: session.workspaceCwd, - isNonPrimary: isNonPrimaryWorkspaceSession( - session.workspaceCwd, - primaryCwd, - ), }; }); cards.sort((a, b) => { @@ -150,38 +312,108 @@ function colorDotClass( } } -function statusClass(status: SessionCardStatus): string { - switch (status) { - case 'needsApproval': - return styles.statusApproval; - case 'running': - return styles.statusRunning; - default: - return styles.statusIdle; - } -} - function SessionOverviewPanelInner({ onOpenSession, onOpenSplit, + onCurrentSessionRemoved, includeOtherWorkspaces, workspaceCwd, + manageLiveState, }: { onOpenSession: (sessionId: string, workspaceCwd?: string) => void; onOpenSplit?: (sessionIds: string[]) => void; + onCurrentSessionRemoved?: ( + session: SessionIdentity, + ) => Promise | boolean | void; includeOtherWorkspaces: boolean; workspaceCwd?: string; + manageLiveState: boolean; }) { const { t } = useI18n(); const connection = useConnection(); + const connectionRef = useRef(connection); + connectionRef.current = connection; + const workspace = useWorkspace(); + const sessionCatalogController = useSessionCatalogController( + workspace.client, + ); + const actions = useActions(); const currentSessionId = connection.sessionId; const organizationEnabled = connection.capabilities?.features?.includes(SESSION_ORGANIZATION_FEATURE) ?? false; + const sessionMetadataEnabled = + connection.capabilities?.features?.includes('workspace_session_metadata') ?? + false; + const sessionArchiveEnabled = + connection.capabilities?.features?.includes('session_archive') ?? false; + const workspaceQualifiedRestCoreEnabled = + connection.capabilities?.features?.includes( + 'workspace_qualified_rest_core', + ) ?? false; + const canExportSessions = + connection.capabilities?.features?.includes('session_export') ?? false; + const canExportWorkspaceSessions = + connection.capabilities?.features?.includes('workspace_session_export') ?? + false; + const registeredWorkspaces = + connection.capabilities?.workspaces ?? workspace.capabilities?.workspaces; + const workspaceCatalogAdvertised = + connection.capabilities?.workspaces !== undefined || + workspace.capabilities?.workspaces !== undefined; + // Prefer the explicitly registered primary. `workspaceCwd` is the legacy + // single-workspace fallback when the daemon does not advertise a catalog. + const primaryCwd = + registeredWorkspaces?.find((entry) => entry.primary)?.cwd ?? + workspace.capabilities?.workspaceCwd ?? + connection.capabilities?.workspaceCwd ?? + connection.workspaceCwd; + const currentWorkspaceCwd = + connection.workspaceCwd || workspaceCwd || primaryCwd; + + // Live-state (2s channel) is the sidebar's refresh path: it patches the + // catalog store's sessions with hasActivePrompt / isWaitingForPermission / + // isWaitingForUserQuestion and coordinates full-catalog reconciles only when + // something actually changed. Adopt it only when trusted live-state routes + // cover every visible workspace; otherwise fall back to catalog polling. + const liveStateWorkspaceCwds = useMemo(() => { + if (!workspaceCatalogAdvertised) { + const legacyCwd = workspaceCwd || primaryCwd; + return legacyCwd ? [legacyCwd] : []; + } + const visible = workspaceCwd + ? (registeredWorkspaces ?? []).filter( + (entry) => entry.cwd === workspaceCwd, + ) + : (registeredWorkspaces ?? []).filter( + (entry) => entry.primary || entry.trusted, + ); + return visible.length > 0 && visible.every((entry) => entry.trusted) + ? visible.map((entry) => entry.cwd) + : []; + }, [ + primaryCwd, + registeredWorkspaces, + workspaceCatalogAdvertised, + workspaceCwd, + ]); + const liveStateEnabled = + (connection.capabilities?.features?.includes(SESSION_LIVE_STATE_FEATURE) ?? + false) && + liveStateWorkspaceCwds.length > 0; + // Live state only replaces catalog/status polling when this panel runs the + // channel itself. When the sidebar owns it, its trusted-only coverage can + // miss workspaces this panel shows — so this panel keeps polling then. + const liveStateActive = manageLiveState && liveStateEnabled; + useWorkspaceSessionLiveState(workspace.client, { + enabled: liveStateActive, + workspaceCwds: liveStateWorkspaceCwds, + groupWorkspaceCwds: [], + }); const { sessions, loading, error, reload } = useScopedSessions(workspaceCwd, { autoLoad: true, - pollIntervalMs: LIST_POLL_MS, + pollIntervalMs: liveStateActive ? undefined : LIST_POLL_MS, pageSize: SESSION_LIST_PAGE_SIZE, archiveState: 'active', ...(organizationEnabled @@ -194,27 +426,34 @@ function SessionOverviewPanelInner({ const { sessions: otherSessions, reload: reloadOther } = useOtherWorkspaceSessions( includeOtherWorkspaces && !workspaceCwd, - LIST_POLL_MS, + liveStateActive ? undefined : LIST_POLL_MS, ); const mergedSessions = useMemo( - () => mergeSessionsById(sessions, otherSessions), + () => + otherSessions.length === 0 ? sessions : [...sessions, ...otherSessions], [sessions, otherSessions], ); + const sessionByIdentity = useMemo( + () => + new Map( + mergedSessions.map((session) => [getSessionIdentity(session), session]), + ), + [mergedSessions], + ); const multiWorkspace = !workspaceCwd && includeOtherWorkspaces && - hasMultipleWorkspaces(connection.capabilities); - const status = useStatusReport({ autoLoad: true, detail: 'full' }); + (registeredWorkspaces?.length ?? 0) > 1; + const status = useStatusReport({ + autoLoad: !liveStateActive, + detail: 'full', + }); const statusReload = status.reload; const statusReport = status.report; - const [selected, setSelected] = useState>(() => new Set()); - const [popupBlocked, setPopupBlocked] = useState(false); - - // 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); useEffect(() => { + if (liveStateActive) return; const timer = window.setInterval(() => { if (document.hidden || statusInFlight.current) return; statusInFlight.current = true; @@ -223,254 +462,1326 @@ function SessionOverviewPanelInner({ }); }, STATUS_POLL_MS); return () => window.clearInterval(timer); - }, [statusReload]); + }, [liveStateActive, statusReload]); - // The primary workspace cwd (not `connection.workspaceCwd`, which follows the - // currently-loaded session and can itself be non-primary) — so cards are - // tagged against the real primary. - const primaryCwd = connection.capabilities?.workspaceCwd; const cards = useMemo( () => deriveSessionCards( mergedSessions, - statusReport?.full?.sessions ?? [], currentSessionId, - primaryCwd, + liveStateActive ? [] : (statusReport?.full?.sessions ?? []), + currentWorkspaceCwd, ), - [mergedSessions, statusReport, currentSessionId, primaryCwd], + [ + mergedSessions, + currentSessionId, + currentWorkspaceCwd, + liveStateActive, + statusReport, + ], + ); + const [popupBlocked, setPopupBlocked] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [excludedWorkspaceCwds, setExcludedWorkspaceCwds] = useState< + Set + >(() => new Set()); + const [workspaceFilterOpen, setWorkspaceFilterOpen] = useState(false); + const [busyIds, setBusyIds] = useState>(() => new Set()); + const [actionError, setActionError] = useState(null); + const [archiveTarget, setArchiveTarget] = useState( + null, + ); + const [deleteTarget, setDeleteTarget] = useState(null); + const [batchBusy, setBatchBusy] = useState(false); + const [footerSticky, setFooterSticky] = useState(false); + const panelRef = useRef(null); + // Inline rename state — mirrors the sidebar's double-click/rename flow. + const [editingCard, setEditingCard] = useState(null); + const [editingName, setEditingName] = useState(''); + const editingIdentity = editingCard + ? getSessionIdentity(editingCard) + : undefined; + const isCurrentCard = useCallback( + (card: SessionCard) => { + const current = connectionRef.current; + return isCurrentSession( + card, + current.sessionId, + current.workspaceCwd || workspaceCwd || primaryCwd, + ); + }, + [primaryCwd, workspaceCwd], ); - const toggleSelected = useCallback((sessionId: string) => { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(sessionId)) next.delete(sessionId); - else next.add(sessionId); - return next; - }); - }, []); - - // Selection drives the batch actions: open the checked sessions each in a new - // tab, or open them together in the in-window split. - const selectedIds = cards - .map((card) => card.sessionId) - .filter((id) => selected.has(id)); - const selectedCount = selectedIds.length; - const allSelected = cards.length > 0 && selectedCount === cards.length; - // The split shows at most MAX_SPLIT_PANES; cap what we hand off so the new-tab - // URL doesn't bloat with ids that get discarded and the in-window path doesn't - // silently open fewer than were checked. The top-ranked selections win. - const splitIds = selectedIds.slice(0, MAX_SPLIT_PANES); - const overCap = selectedCount > MAX_SPLIT_PANES; - const toggleSelectAll = useCallback(() => { - setSelected((prev) => { - const ids = cards.map((card) => card.sessionId); - // Toggle off only when every currently-listed card is selected — using - // the intersection, not prev.size, so stale ids can't skew it. - const everySelected = ids.length > 0 && ids.every((id) => prev.has(id)); - return everySelected ? new Set() : new Set(ids); - }); - }, [cards]); + // Workspace filter options (the primary plus trusted secondaries); hidden + // when the panel is locked to a single workspace or the daemon has only one. + const workspaceOptions = useMemo(() => { + if (workspaceCwd || !multiWorkspace) return []; + const workspaces = registeredWorkspaces ?? []; + const listed = workspaces.filter((entry) => entry.primary || entry.trusted); + return listed.map((entry) => ({ + cwd: entry.cwd, + label: workspaceLabel(entry), + })); + }, [multiWorkspace, registeredWorkspaces, workspaceCwd]); - // Drop selections for sessions that have left the list, so a reappearing - // session isn't silently pre-selected and select-all/counts stay accurate. + // Exclusions are only manageable through the filter popover. When the + // option set shrinks — most sharply when the host locks the panel to one + // workspace and the popover disappears — drop any exclusion the user can + // no longer see or change, so a stale one can't hide every remaining row. useEffect(() => { - setSelected((prev) => { - if (prev.size === 0) return prev; - const present = new Set(cards.map((card) => card.sessionId)); - let changed = false; - const next = new Set(); - for (const id of prev) { - if (present.has(id)) next.add(id); - else changed = true; - } - return changed ? next : prev; + if (excludedWorkspaceCwds.size === 0) return; + // The funnel popover only renders with two or more options; with one or + // none left, any exclusion is one the user can no longer see or change. + if (workspaceOptions.length <= 1) { + setExcludedWorkspaceCwds((prev) => + prev.size === 0 ? prev : new Set(), + ); + return; + } + const optionCwds = new Set(workspaceOptions.map((option) => option.cwd)); + setExcludedWorkspaceCwds((prev) => { + const next = new Set([...prev].filter((cwd) => optionCwds.has(cwd))); + return next.size === prev.size ? prev : next; }); - }, [cards]); + }, [excludedWorkspaceCwds, workspaceOptions]); + + const filteredCards = useMemo(() => { + let list = cards; + if (excludedWorkspaceCwds.size > 0) { + list = list.filter( + (card) => !excludedWorkspaceCwds.has(card.workspaceCwd), + ); + } + const query = searchQuery.trim().toLowerCase(); + if (query) { + list = list.filter( + (card) => + card.label.toLowerCase().includes(query) || + card.sessionId.toLowerCase().includes(query), + ); + } + return list; + }, [cards, excludedWorkspaceCwds, searchQuery]); + + const isPrimaryCard = useCallback( + (card: SessionCard) => { + const workspaceEntry = registeredWorkspaces?.find( + (entry) => entry.cwd === card.workspaceCwd, + ); + if (workspaceEntry && !workspaceEntry.trusted) return false; + if (workspaceEntry?.primary) return true; + return ( + !workspaceEntry && + !workspaceCatalogAdvertised && + card.workspaceCwd === primaryCwd + ); + }, + [primaryCwd, registeredWorkspaces, workspaceCatalogAdvertised], + ); + const isRegisteredTrustedCard = useCallback( + (card: SessionCard) => + registeredWorkspaces?.some( + (entry) => entry.cwd === card.workspaceCwd && entry.trusted, + ) === true, + [registeredWorkspaces], + ); + const canUseSessionMutation = useCallback( + (card: SessionCard) => + isPrimaryCard(card) || + (isRegisteredTrustedCard(card) && workspaceQualifiedRestCoreEnabled), + [isPrimaryCard, isRegisteredTrustedCard, workspaceQualifiedRestCoreEnabled], + ); + const isLockedTrustedCard = useCallback( + (card: SessionCard) => + currentWorkspaceCwd === card.workspaceCwd && + isRegisteredTrustedCard(card), + [currentWorkspaceCwd, isRegisteredTrustedCard], + ); + const canArchiveCard = useCallback( + (card: SessionCard) => + sessionArchiveEnabled && + card.status === 'idle' && + canUseSessionMutation(card), + [canUseSessionMutation, sessionArchiveEnabled], + ); + const canDeleteCard = useCallback( + (card: SessionCard) => + card.status === 'idle' && canUseSessionMutation(card), + [canUseSessionMutation], + ); + const canRenameCard = useCallback( + (card: SessionCard) => + (isCurrentCard(card) && + (isPrimaryCard(card) || isLockedTrustedCard(card))) || + (sessionMetadataEnabled && canUseSessionMutation(card)), + [ + canUseSessionMutation, + isLockedTrustedCard, + isPrimaryCard, + isCurrentCard, + sessionMetadataEnabled, + ], + ); + const canExportCard = useCallback( + (card: SessionCard) => + isPrimaryCard(card) + ? canExportSessions + : isRegisteredTrustedCard(card) && canExportWorkspaceSessions, + [ + canExportSessions, + canExportWorkspaceSessions, + isPrimaryCard, + isRegisteredTrustedCard, + ], + ); // Open the selected sessions as a split view in a NEW browser tab: one tab // showing all of them side by side (not one tab per session). Passing no // window features makes browsers open a tab rather than a popup window. - const openSelectedInNewTab = useCallback(() => { - if (splitIds.length === 0) return; - // Carry the (already-stripped-from-the-URL) daemon token so the new tab can - // authenticate on token-auth deployments. - const url = buildSplitUrl(splitIds, window.location.href, getDaemonToken()); + const openInNewTab = useCallback((sessionIds: string[]) => { + if (sessionIds.length === 0) return; + // Carry the (already-stripped-from-the-URL) daemon token so the new tab + // can authenticate on token-auth deployments. + const url = buildSplitUrl( + sessionIds, + window.location.href, + getDaemonToken(), + ); const win = window.open(url, '_blank'); if (win) { // The split tab carries a daemon token in its URL fragment; sever the // opener link so it can't script the shell that spawned it during an - // authenticated session (reverse tabnabbing). Mirrors the bug-report path. + // authenticated session (reverse tabnabbing). Mirrors the bug-report + // path. win.opener = null; win.focus(); } setPopupBlocked(!win); - }, [splitIds]); + }, []); + const reloadData = useCallback( + () => + Promise.all([ + reload().catch(() => undefined), + reloadOther().catch(() => undefined), + liveStateActive + ? Promise.resolve() + : statusReload().catch(() => undefined), + ]), + [liveStateActive, reload, reloadOther, statusReload], + ); const refresh = useCallback(() => { - void reload().catch(() => undefined); - void reloadOther().catch(() => undefined); - void statusReload().catch(() => undefined); - }, [reload, reloadOther, statusReload]); - - if (cards.length === 0) { - return ( -
-
- {loading - ? t('sessionsOverview.loading') - : error - ? `${t('sessionsOverview.loadFailed')}: ${error.message}` - : t('sessionsOverview.empty')} -
-
- ); - } + if (refreshing) return; + setActionError(null); + setRefreshing(true); + void reloadData().finally(() => setRefreshing(false)); + }, [refreshing, reloadData]); - return ( -
-
- - {t('sessionsOverview.count', { count: cards.length })} - - - - {onOpenSplit && ( - - )} - -
+ // Route each batch through its owning workspace client. + const mutateCards = useCallback( + async (cards: SessionCard[], mutation: 'archive' | 'delete') => { + const canMutate = mutation === 'archive' ? canArchiveCard : canDeleteCard; + if (!cards.every(canMutate)) { + throw new Error(t('sessionsOverview.actionUnavailable')); + } + const byCwd = new Map(); + for (const card of cards) { + const key = + !card.workspaceCwd || card.workspaceCwd === primaryCwd + ? '' + : card.workspaceCwd; + byCwd.set(key, [...(byCwd.get(key) ?? []), card]); + } + const succeededIdentities = new Set(); + let firstError: Error | undefined; + for (const [cwd, group] of byCwd) { + const ids = group.map((card) => card.sessionId); + const ownerCwd = cwd || primaryCwd; + try { + const client = cwd + ? workspace.client.workspaceByCwd(cwd) + : workspace.client; + const cardsById = new Map( + group.map((card) => [card.sessionId, card]), + ); + if (mutation === 'archive') { + const result = await client.archiveSessionsData(ids); + for (const id of [ + ...result.archived, + ...result.alreadyArchived, + ...result.notFound, + ]) { + const card = cardsById.get(id); + if (card) succeededIdentities.add(getSessionIdentity(card)); + } + firstError ??= result.errors[0] + ? new Error(result.errors[0].error) + : undefined; + } else { + const result = await client.deleteSessionsData(ids); + for (const id of [...result.removed, ...result.notFound]) { + const card = cardsById.get(id); + if (card) succeededIdentities.add(getSessionIdentity(card)); + } + firstError ??= result.errors[0] + ? new Error(result.errors[0].error) + : undefined; + } + } catch (error) { + firstError ??= + error instanceof Error ? error : new Error(String(error)); + } finally { + if (ownerCwd) { + sessionCatalogController.refreshWorkspace(ownerCwd); + } + } + } + return { succeededIdentities, error: firstError }; + }, + [ + canArchiveCard, + canDeleteCard, + primaryCwd, + sessionCatalogController, + t, + workspace.client, + ], + ); - {overCap && ( -
- {t('sessionsOverview.splitCap', { max: MAX_SPLIT_PANES })} -
- )} - {popupBlocked && ( -
- {t('sessionsOverview.popupBlocked')} -
- )} - {/* Cards are already on screen (empty-state error path didn't run), so a - later failed refresh would otherwise be silent — surface it inline and - keep showing the last-good cards below. */} - {error && ( -
- {t('sessionsOverview.loadFailed')}: {error.message} -
- )} + const runBusy = useCallback( + async (card: SessionCard, operation: () => Promise) => { + const identity = getSessionIdentity(card); + setBusyIds((prev) => new Set(prev).add(identity)); + try { + await operation(); + } finally { + setBusyIds((prev) => { + const next = new Set(prev); + next.delete(identity); + return next; + }); + } + }, + [], + ); -
    - {cards.map((card) => ( -
  • -
    - toggleSelected(card.sessionId)} - aria-label={t('sessionsOverview.selectSession', { - name: card.label, - })} - /> + const startRename = useCallback((card: SessionCard) => { + setActionError(null); + setEditingCard(card); + setEditingName(card.label); + }, []); + + const cancelRename = useCallback(() => { + setEditingCard(null); + setEditingName(''); + }, []); + + const saveRename = useCallback(() => { + const card = editingCard; + const nextName = editingName.trim(); + if (!card || !nextName) { + cancelRename(); + return; + } + if (batchBusy) return; + if (!canRenameCard(card)) { + cancelRename(); + setActionError(t('sessionsOverview.actionUnavailable')); + return; + } + if (nextName === card.label) { + cancelRename(); + return; + } + cancelRename(); + void runBusy(card, async () => { + const ownerCwd = card.workspaceCwd || primaryCwd; + try { + // The current session renames through its own session actions (the + // daemon only allows it there); other sessions update metadata on the + // owning workspace client — mirroring the sidebar. + let result: SessionMetadataResult | void; + if (isCurrentCard(card)) { + result = await actions.renameSession(nextName); + } else if (card.workspaceCwd) { + result = await workspace.client + .workspaceByCwd(card.workspaceCwd) + .updateSessionMetadata(card.sessionId, { displayName: nextName }); + } else { + result = await workspace.client.updateSessionMetadata( + card.sessionId, + { + displayName: nextName, + }, + ); + } + if (ownerCwd) { + sessionCatalogController.renamed( + ownerCwd, + card.sessionId, + result?.displayName || nextName, + ); + sessionCatalogController.refreshWorkspace(ownerCwd); + } + await reloadData(); + } catch (err) { + if (ownerCwd) { + sessionCatalogController.refreshWorkspace(ownerCwd); + } + setActionError( + err instanceof Error + ? `${t('sidebar.renameFailed')}: ${err.message}` + : t('sidebar.renameFailed'), + ); + } + }); + }, [ + actions, + batchBusy, + canRenameCard, + cancelRename, + editingCard, + editingName, + isCurrentCard, + primaryCwd, + reloadData, + runBusy, + sessionCatalogController, + t, + workspace.client, + ]); + const editingNameRef = useRef(editingName); + const saveRenameRef = useRef(saveRename); + editingNameRef.current = editingName; + saveRenameRef.current = saveRename; + + // Export the conversation as a downloadable HTML file, mirroring the + // sidebar's export flow (blob + anchor download). + const handleExport = useCallback( + (card: SessionCard) => { + if (batchBusy) return; + setActionError(null); + if (!canExportCard(card)) { + setActionError(t('sessionsOverview.actionUnavailable')); + return; + } + void runBusy(card, async () => { + try { + const result = + !card.workspaceCwd || card.workspaceCwd === primaryCwd + ? await workspace.actions.exportSession(card.sessionId, 'html') + : await workspace.client + .workspaceByCwd(card.workspaceCwd) + .exportSession(card.sessionId, { format: 'html' }); + const blob = new Blob([result.content], { + type: result.mimeType || 'text/html', + }); + const url = URL.createObjectURL(blob); + try { + const link = document.createElement('a'); + link.href = url; + link.download = result.filename; + document.body.appendChild(link); + link.click(); + link.remove(); + } finally { + URL.revokeObjectURL(url); + } + } catch (err) { + setActionError( + err instanceof Error + ? `${t('sidebar.exportFailed')}: ${err.message}` + : t('sidebar.exportFailed'), + ); + } + }); + }, + [ + batchBusy, + canExportCard, + primaryCwd, + runBusy, + t, + workspace.actions, + workspace.client, + ], + ); + + const handleBatchArchive = useCallback( + (cards: SessionCard[]) => { + if (cards.length === 0 || batchBusy) return; + setActionError(null); + setBatchBusy(true); + void (async () => { + try { + const result = await mutateCards(cards, 'archive'); + const current = cards.find( + (card) => + isCurrentCard(card) && + result.succeededIdentities.has(getSessionIdentity(card)), + ); + if (current) { + const cleared = await onCurrentSessionRemoved?.(current); + if (cleared === false) { + setActionError(t('sidebar.newSessionFailed')); + return; + } + } + if (result.error) throw result.error; + } catch (err) { + setActionError( + err instanceof Error + ? `${t('sessionsOverview.archiveFailed')}: ${err.message}` + : t('sessionsOverview.archiveFailed'), + ); + } finally { + await reloadData(); + setBatchBusy(false); + } + })(); + }, + [ + batchBusy, + isCurrentCard, + mutateCards, + onCurrentSessionRemoved, + reloadData, + t, + ], + ); + + const confirmArchive = useCallback(() => { + const cards = archiveTarget; + if (!cards || cards.length === 0) return; + setArchiveTarget(null); + handleBatchArchive(cards); + }, [archiveTarget, handleBatchArchive]); + + const confirmDelete = useCallback(() => { + const cards = deleteTarget; + if (!cards || cards.length === 0 || batchBusy) return; + setDeleteTarget(null); + setActionError(null); + setBatchBusy(true); + void (async () => { + try { + const result = await mutateCards(cards, 'delete'); + const current = cards.find( + (card) => + isCurrentCard(card) && + result.succeededIdentities.has(getSessionIdentity(card)), + ); + if (current) { + const cleared = await onCurrentSessionRemoved?.(current); + if (cleared === false) { + setActionError(t('sidebar.newSessionFailed')); + return; + } + } + if (result.error) throw result.error; + } catch (err) { + setActionError( + err instanceof Error + ? `${t('sessionsOverview.deleteFailed')}: ${err.message}` + : t('sessionsOverview.deleteFailed'), + ); + } finally { + await reloadData(); + setBatchBusy(false); + } + })(); + }, [ + batchBusy, + deleteTarget, + isCurrentCard, + mutateCards, + onCurrentSessionRemoved, + reloadData, + t, + ]); + + // ── Data table state ────────────────────────────────────────────────── + const [sorting, setSorting] = useState([]); + const [pagination, setPagination] = useState(() => ({ + pageIndex: 0, + pageSize: readPageSize(), + })); + const [rowSelection, setRowSelection] = useState({}); + + // User-driven filters reset the view; live status/catalog updates keep the + // current page and any selections whose session ids still exist. + useEffect(() => { + setPagination((prev) => + prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }, + ); + setRowSelection({}); + }, [excludedWorkspaceCwds, searchQuery]); + useEffect(() => { + const validIds = new Set(filteredCards.map(getSessionIdentity)); + setRowSelection((prev) => { + const next = Object.fromEntries( + Object.entries(prev).filter(([identity]) => validIds.has(identity)), + ); + return Object.keys(next).length === Object.keys(prev).length + ? prev + : next; + }); + }, [filteredCards]); + useEffect(() => { + setPagination((prev) => { + const lastPageIndex = Math.max( + 0, + Math.ceil(filteredCards.length / prev.pageSize) - 1, + ); + return prev.pageIndex <= lastPageIndex + ? prev + : { ...prev, pageIndex: lastPageIndex }; + }); + }, [filteredCards.length]); + + const columns = useMemo[]>( + () => [ + { + id: 'select', + header: ({ table }) => ( + table.toggleAllRowsSelected(!!value)} + aria-label={t('sessionsOverview.selectAll')} + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label={t('sessionsOverview.selectSession', { + name: row.original.label, + })} + /> + ), + meta: { + fixed: 'left', + width: 40, + fixedWidth: true, + stopRowClick: true, + } satisfies DataTableColumnMeta, + }, + { + id: 'title', + header: t('sessionsOverview.titleColumn'), + cell: ({ row }) => { + const card = row.original; + return ( +
    {card.color && (
    -
    + ); + }, + meta: { + fixed: 'left', + fixedEdge: true, + width: 224, + fluidWeight: 22, + truncate: (card) => editingIdentity !== getSessionIdentity(card), + } satisfies DataTableColumnMeta, + }, + { + id: 'git', + header: t('sessionsOverview.worktree'), + cell: ({ row }) => { + const card = row.original; + const { gitBranch, prs } = card; + const session = sessionByIdentity.get(getSessionIdentity(card)) ?? { + sessionId: card.sessionId, + workspaceCwd: card.workspaceCwd, + clientCount: 0, + hasActivePrompt: card.status !== 'idle', + prs, + branch: gitBranch ? { name: gitBranch, baseBranch: '' } : undefined, + }; + const content = ( +
    - {t(`sessionsOverview.status.${card.status}`)} + {gitBranch ?? '-'} - {multiWorkspace && ( - +
    + ); + if (!gitBranch && !prs?.some((pr) => isExternalOpenUrl(pr.url))) { + return content; + } + return ( + + {content} + + ); + }, + meta: { + width: 144, + fluidWeight: 20, + } satisfies DataTableColumnMeta, + }, + { + id: 'sessionId', + header: t('sessionsOverview.sessionId'), + cell: ({ row }) => , + meta: { + width: 136, + fluidWeight: 18, + } satisfies DataTableColumnMeta, + }, + { + id: 'workspace', + header: () => ( +
    + {t('sessionsOverview.folder')} + {workspaceOptions.length > 1 && ( + + + + + - {workspaceLabelForCwd( - card.workspaceCwd, - connection.capabilities?.workspaces, - )} - - )} - {card.model && ( - - {card.model} - - )} - {card.clientCount > 0 && ( - + !excludedWorkspaceCwds.has(option.cwd), + ) || + (workspaceOptions.some( + (option) => !excludedWorkspaceCwds.has(option.cwd), + ) && + 'indeterminate') + } + onCheckedChange={(checked) => + setExcludedWorkspaceCwds( + checked === true + ? new Set() + : new Set(workspaceOptions.map(({ cwd }) => cwd)), + ) + } + /> + {t('sessionsOverview.allWorkspaces')} + +
    + {workspaceOptions.map((option, index) => { + const id = `session-overview-workspace-${index}`; + return ( + + ); + })} +
    +
    +
    + )} +
    + ), + cell: ({ row }) => ( + + {workspaceLabelForCwd( + row.original.workspaceCwd, + registeredWorkspaces, + )} + + ), + meta: { + width: 128, + fluidWeight: 12, + tooltip: (card) => card.workspaceCwd, + } satisfies DataTableColumnMeta, + }, + { + id: 'updatedAt', + accessorFn: (card) => card.updatedAt ?? '', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.updatedAt + ? formatRelativeTime(row.original.updatedAt, t) + : ''} + + ), + meta: { + width: 112, + fluidWeight: 5, + } satisfies DataTableColumnMeta, + }, + { + id: 'actions', + header: t('sessionsOverview.actions'), + cell: ({ row }) => { + const card = row.original; + const busy = busyIds.has(getSessionIdentity(card)); + const canArchive = canArchiveCard(card); + const canDelete = canDeleteCard(card); + const canRename = canRenameCard(card); + const canExport = canExportCard(card); + return ( +
    + {(isCurrentCard(card) || sessionMetadataEnabled) && ( + )} - {card.updatedAt && ( - - {formatRelativeTime(card.updatedAt, t)} - + {(canExportSessions || canExportWorkspaceSessions) && ( + )} + +
    -
  • - ))} -
+ ); + }, + meta: { + fixed: 'right', + fixedEdge: true, + width: 128, + fixedWidth: true, + headerClassName: 'text-center', + stopRowClick: true, + } satisfies DataTableColumnMeta, + }, + ], + [ + batchBusy, + busyIds, + canArchiveCard, + canDeleteCard, + canExportCard, + canExportSessions, + canExportWorkspaceSessions, + canRenameCard, + cancelRename, + editingIdentity, + handleExport, + isCurrentCard, + onOpenSession, + registeredWorkspaces, + excludedWorkspaceCwds, + sessionMetadataEnabled, + sessionByIdentity, + startRename, + t, + workspaceOptions, + workspaceFilterOpen, + ], + ); + + const table = useReactTable({ + data: filteredCards, + columns, + state: { sorting, pagination, rowSelection }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getRowId: getSessionIdentity, + autoResetPageIndex: false, + }); + const selectedCards = table + .getSortedRowModel() + .rows.filter((row) => row.getIsSelected()) + .map((row) => row.original); + const selectedCount = selectedCards.length; + const canOpenSelection = + selectedCount > 0 && + selectedCount <= MAX_SPLIT_PANES && + selectedCards.every((selected) => + cards.every( + (card) => + card.sessionId !== selected.sessionId || + card.workspaceCwd === selected.workspaceCwd, + ), + ); + const canArchiveSelection = + selectedCount > 0 && selectedCards.every(canArchiveCard); + const canDeleteSelection = + selectedCount > 0 && selectedCards.every(canDeleteCard); + const splitIds = selectedCards.map((card) => card.sessionId); + useEffect(() => { + const panel = panelRef.current; + const viewport = panel?.parentElement; + const tableViewport = panel?.querySelector( + '[data-slot="data-table-viewport"]', + ); + if ( + !panel || + !viewport || + !tableViewport || + typeof ResizeObserver === 'undefined' + ) { + return; + } + const update = () => { + const naturalHeight = + panel.scrollHeight - + tableViewport.clientHeight + + tableViewport.scrollHeight; + const viewportStyle = getComputedStyle(viewport); + const viewportContentHeight = + viewport.clientHeight - + (Number.parseFloat(viewportStyle.paddingTop) || 0) - + (Number.parseFloat(viewportStyle.paddingBottom) || 0); + // Sticky decorations add 13px to the measured height + // (pt-3 +12, border-t +1). Keep a small hysteresis around the + // normalized threshold to avoid oscillation. + setFooterSticky( + (prev) => naturalHeight > viewportContentHeight + (prev ? 12 : 1), + ); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(panel); + observer.observe(viewport); + observer.observe(tableViewport); + const table = tableViewport.querySelector('[data-slot="table"]'); + if (table) observer.observe(table); + return () => observer.disconnect(); + }, [filteredCards.length, pagination.pageSize]); + + const toolbar = ( +
+
+ + setSearchQuery(event.target.value)} + placeholder={t('sessionsOverview.searchPlaceholder')} + className="h-7 w-full pl-7 text-xs" + aria-label={t('sessionsOverview.searchPlaceholder')} + /> +
+ +
+ ); + + return ( +
+ {/* Row 1: search + workspace filter + refresh (right). */} + {toolbar} + + {popupBlocked && ( +
+ {t('sessionsOverview.popupBlocked')} +
+ )} + {actionError && ( +
+ {actionError} +
+ )} + {error && cards.length > 0 && ( +
+ {t('sessionsOverview.loadFailed')}: {error.message} +
+ )} + + 0 + ? t('sessionsOverview.noData') + : loading + ? t('sessionsOverview.loading') + : error + ? `${t('sessionsOverview.loadFailed')}: ${error.message}` + : t('sessionsOverview.empty') + } + className={styles.tableViewport} + rowClassName="cursor-pointer" + onRowClick={(row) => row.toggleSelected()} + data-web-shell-session-table-viewport + /> + + + {filteredCards.length > 0 && ( +
+ + {t('sessionsOverview.selectedRows', { + count: selectedCount, + total: filteredCards.length, + })} + +
+ + + + {onOpenSplit && ( + + )} +
+ + t('sessionsOverview.pageInfo', { page, total }), + }} + onPageSizeChange={writePageSize} + /> +
+ )} + + { + if (!open) setArchiveTarget(null); + }} + > + + + + {archiveTarget && archiveTarget.length > 1 + ? t('sessionsOverview.confirmArchiveBulkTitle', { + count: archiveTarget.length, + }) + : t('sessionsOverview.confirmArchiveTitle')} + + + {archiveTarget && archiveTarget.length > 1 + ? t('sessionsOverview.confirmArchiveBulk', { + count: archiveTarget.length, + }) + : t('sessionsOverview.confirmArchive', { + name: archiveTarget?.[0]?.label ?? '', + })} + + + + {t('common.cancel')} + + {t('sidebar.archive')} + + + + + + { + if (!open) setDeleteTarget(null); + }} + > + + + + {deleteTarget && deleteTarget.length > 1 + ? t('sessionsOverview.confirmDeleteBulkTitle', { + count: deleteTarget.length, + }) + : t('sessionsOverview.confirmDeleteTitle')} + + + {deleteTarget && deleteTarget.length > 1 + ? t('sessionsOverview.confirmDeleteBulk', { + count: deleteTarget.length, + }) + : t('sessionsOverview.confirmDelete', { + name: deleteTarget?.[0]?.label ?? '', + })} + + + + {t('common.cancel')} + + {t('sidebar.delete')} + + + +
); } @@ -482,13 +1793,19 @@ function SessionOverviewPanelInner({ export function SessionOverviewPanel({ onOpenSession, onOpenSplit, + onCurrentSessionRemoved, includeOtherWorkspaces = true, workspaceCwd, + manageLiveState = true, }: { onOpenSession: (sessionId: string, workspaceCwd?: string) => void; onOpenSplit?: (sessionIds: string[]) => void; + onCurrentSessionRemoved?: ( + session: SessionIdentity, + ) => Promise | boolean | void; includeOtherWorkspaces?: boolean; workspaceCwd?: string; + manageLiveState?: boolean; }) { const { t } = useI18n(); return ( @@ -505,8 +1822,10 @@ export function SessionOverviewPanel({ ); diff --git a/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx b/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx index 6b9f280453e..1ff03e608c6 100644 --- a/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx +++ b/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx @@ -22,6 +22,7 @@ interface SessionDetailsTooltipProps { label: string; time: string; completedUnread: boolean; + worktreeOnly?: boolean; children: ReactElement; } @@ -30,6 +31,7 @@ export function SessionDetailsTooltip({ label, time, completedUnread, + worktreeOnly = false, children, }: SessionDetailsTooltipProps) { const { t } = useI18n(); @@ -115,7 +117,7 @@ export function SessionDetailsTooltip({ -
- - {label} - - {time && {time}} -
-
-
+ {!worktreeOnly && ( + <> +
+ + {label} + + {time && ( + {time} + )} +
+
+
+ + )} {branch && (
); })} -
-
-
- {session.sessionId} - - - {copyStatus === 'copied' - ? t('sidebar.sessionIdCopied') - : copyStatus === 'failed' - ? t('sidebar.copySessionIdFailed') - : ''} - -
+ }); + }} + > + {copyStatus === 'copied' ? ( +
+ + )} ); diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css index dd1e4ceccfd..c18a3dd5c02 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css @@ -742,8 +742,6 @@ align-items: stretch; gap: 10px; padding: 12px 14px; - border: 1px solid var(--border); - box-shadow: none; font-size: 12px; line-height: 20px; } diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index aae715e3ba8..9af26526829 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -266,7 +266,6 @@ export type WebShellSidebarSessionActionItem = /** Subset of action items that have working inline (hover-button) handlers. */ export type WebShellSidebarSessionInlineActionItem = | 'pin' - | 'archive' | 'rename' | 'export' | 'delete'; @@ -279,7 +278,8 @@ export interface WebShellSidebarSessionActionsOptions { * archive stays in the dropdown so a stray click on the hover slot cannot * archive a session. * Only items that also pass their built-in visibility condition are rendered. - * Only items with working inline handlers are accepted (details/group are dropdown-only). + * Only items with working inline handlers are accepted + * (details/group/archive are dropdown-only). */ inlineItems?: readonly WebShellSidebarSessionInlineActionItem[]; } @@ -350,9 +350,8 @@ interface WebShellSidebarProps { onOpenGoals: () => void; onOpenSessions: () => void; /** - * Whether to offer the Session Overview entry point. Gated to large screens - * by the app: below that there is no room to make managing several sessions - * side by side worthwhile. + * Whether to offer the Session Overview entry point. The table handles + * narrow widths, so the entry point can be offered at every viewport size. */ canOpenSessionsOverview?: boolean; onOpenSplitView: () => void; @@ -4013,13 +4012,12 @@ export function WebShellSidebar({ const showDelete = canShowDeleteSession(session); const inlineActionCount = Number(showPin && inlineActionItems.has('pin')) + - Number(showArchive && inlineActionItems.has('archive')) + Number(showRename && inlineActionItems.has('rename')) + Number(showExport && inlineActionItems.has('export')) + Number(showDelete && inlineActionItems.has('delete')); const showMoreActions = (showPin && !inlineActionItems.has('pin')) || - (showArchive && !inlineActionItems.has('archive')) || + showArchive || (showRename && !inlineActionItems.has('rename')) || canOrganizeSession(session, 'group') || (showExport && !inlineActionItems.has('export')) || @@ -4154,20 +4152,6 @@ export function WebShellSidebar({ visible: showPin && inlineActionItems.has('pin'), onClick: () => handleTogglePin(session), }, - { - key: 'archive', - icon: , - label: t('sidebar.archive'), - disabled: busy || isCurrent || running, - title: isCurrent - ? t('sidebar.archiveCurrentDisabled') - : running - ? t('sidebar.archiveRunningDisabled') - : t('sidebar.archive'), - visible: - showArchive && inlineActionItems.has('archive'), - onClick: () => handleArchive(session), - }, { key: 'rename', icon: , @@ -4263,23 +4247,22 @@ export function WebShellSidebar({ : t('sidebar.pin')} )} - {showArchive && - !inlineActionItems.has('archive') && ( - handleArchive(session)} - > - - {t('sidebar.archive')} - - )} + {showArchive && ( + handleArchive(session)} + > + + {t('sidebar.archive')} + + )} {showRename && !inlineActionItems.has('rename') && ( { const expandWorkspace = ensureWorkspaceExpanded; -/** Opt back into the inline archive button; the default keeps it dropdown-only. */ -const INLINE_ARCHIVE_ACTIONS = { - inlineItems: ['pin', 'archive'], -} as const; - function archiveButtonFor(label: string): HTMLButtonElement | undefined { return Array.from( container.querySelectorAll( @@ -647,6 +636,17 @@ async function selectSessionMenuItem( label: string, itemLabel: string, ): Promise { + const item = await openSessionMenuItem(label, itemLabel); + await act(async () => { + click(item); + await Promise.resolve(); + }); +} + +async function openSessionMenuItem( + label: string, + itemLabel: string, +): Promise { const trigger = sessionAction(label); expect(trigger).toBeDefined(); await act(async () => { @@ -657,10 +657,7 @@ async function selectSessionMenuItem( document.body.querySelectorAll('[role="menuitem"]'), ).find((candidate) => candidate.textContent?.includes(itemLabel)); expect(item).toBeDefined(); - await act(async () => { - click(item!); - await Promise.resolve(); - }); + return item!; } async function openSessionMenuItems(label: string): Promise { @@ -1044,7 +1041,7 @@ describe('WebShellSidebar workspace removal', () => { renderSidebar({ lockedWorkspaceCwd: '/tmp/other', - sessionActions: INLINE_ARCHIVE_ACTIONS, + sessionActions: { inlineItems: ['pin'] }, }); await act(async () => { await Promise.resolve(); @@ -1052,7 +1049,6 @@ describe('WebShellSidebar workspace removal', () => { await ensureWorkspaceExpanded('other'); expect(inlineSessionAction('Current secondary', 'Pin')).toBeDefined(); - expect(archiveButtonFor('Other secondary')).toBeDefined(); expect(sessionAction('Current secondary')).toBeDefined(); expect(sessionAction('Other secondary')).toBeDefined(); @@ -1074,8 +1070,8 @@ describe('WebShellSidebar workspace removal', () => { }); expect(primaryExport).not.toHaveBeenCalled(); + await selectSessionMenuItem('Other secondary', 'Archive'); await act(async () => { - click(archiveButtonFor('Other secondary')!); await secondaryArchive.mock.results.at(-1)?.value; }); expect(secondaryArchive).toHaveBeenCalledWith(['other-secondary']); @@ -1119,7 +1115,7 @@ describe('WebShellSidebar workspace removal', () => { lockedWorkspaceCwd: '/tmp/other', sessionActions: { items: ['rename', 'delete', 'archive'], - inlineItems: ['rename', 'delete', 'archive'], + inlineItems: ['rename', 'delete'], }, }); await expandWorkspace('other'); @@ -1127,9 +1123,11 @@ describe('WebShellSidebar workspace removal', () => { expect(inlineSessionAction('Locked current', 'Delete')?.disabled).toBe( true, ); - expect(inlineSessionAction('Locked current', 'Archive')?.disabled).toBe( - true, - ); + expect( + (await openSessionMenuItem('Locked current', 'Archive')).getAttribute( + 'aria-disabled', + ), + ).toBe('true'); expect(inlineSessionAction('Locked other', 'Rename')?.disabled).toBe(false); expect(inlineSessionAction('Locked current', 'Rename')?.disabled).toBe( false, @@ -1503,7 +1501,7 @@ describe('WebShellSidebar workspace removal', () => { lockedWorkspaceCwd: '/tmp/other', sessionActions: { items: ['pin', 'group', 'archive', 'delete', 'export', 'details'], - inlineItems: ['pin', 'archive', 'delete'], + inlineItems: ['pin', 'delete'], }, }); await expandWorkspace('other'); @@ -2146,14 +2144,18 @@ describe('WebShellSidebar workspace removal', () => { 'export', 'delete', ], - inlineItems: ['rename', 'pin', 'archive', 'export', 'delete'], + inlineItems: ['rename', 'pin', 'export', 'delete'], }, }); await expandWorkspace('project'); expect(inlineSessionAction('Legacy primary', 'Rename')).toBeUndefined(); expect(inlineSessionAction('Legacy primary', 'Pin')).toBeDefined(); - expect(archiveButtonFor('Legacy primary')?.disabled).toBe(false); + expect( + (await openSessionMenuItem('Legacy primary', 'Archive')).hasAttribute( + 'data-disabled', + ), + ).toBe(false); expect( inlineSessionAction('Legacy primary', 'Export conversation record'), ).toBeDefined(); @@ -2165,7 +2167,7 @@ describe('WebShellSidebar workspace removal', () => { inlineSessionAction('Legacy primary', 'Pin') ?.closest('[class*="sessionMetaSlot"]') ?.style.getPropertyValue('--session-actions-width'), - ).toBe('130px'); + ).toBe('104px'); }); it('fails closed for an explicit primary cwd that disappears from the catalog', async () => { @@ -2194,7 +2196,7 @@ describe('WebShellSidebar workspace removal', () => { const mutationActions = { items: ['rename', 'pin', 'group', 'archive', 'export', 'delete'] as const, - inlineItems: ['rename', 'pin', 'archive', 'export', 'delete'] as const, + inlineItems: ['rename', 'pin', 'export', 'delete'] as const, }; renderSidebar({ sessionActions: mutationActions }); await expandWorkspace('project'); @@ -2258,25 +2260,32 @@ describe('WebShellSidebar workspace removal', () => { renderSidebar({ sessionActions: { items: ['rename', 'archive', 'delete'], - inlineItems: ['rename', 'archive', 'delete'], + inlineItems: ['rename', 'delete'], }, }); await expandWorkspace('project'); await expandWorkspace('other'); const rename = inlineSessionAction('Current no-cwd primary', 'Rename'); - const archive = archiveButtonFor('Current no-cwd primary'); const remove = inlineSessionAction('Current no-cwd primary', 'Delete'); expect(rename?.disabled).toBe(false); - expect(archive?.disabled).toBe(true); expect(remove?.disabled).toBe(true); - expect(archiveButtonFor('Equal-id secondary')?.disabled).toBe(false); + const archive = await openSessionMenuItem( + 'Current no-cwd primary', + 'Archive', + ); + expect(archive.getAttribute('aria-disabled')).toBe('true'); await act(async () => { - click(archive!); + click(archive); click(remove!); await Promise.resolve(); }); + expect( + (await openSessionMenuItem('Equal-id secondary', 'Archive')).hasAttribute( + 'data-disabled', + ), + ).toBe(false); expect(document.body.textContent).not.toContain('Delete Session'); expect(active.archiveSession).not.toHaveBeenCalled(); @@ -2746,7 +2755,9 @@ describe('WebShellSidebar workspace removal', () => { exportArchivedSession, })); - renderSidebar({ sessionActions: INLINE_ARCHIVE_ACTIONS }); + renderSidebar({ + sessionActions: { inlineItems: ['pin'] }, + }); await expandWorkspace('other'); await expandArchived(); await act(async () => { @@ -2754,14 +2765,14 @@ describe('WebShellSidebar workspace removal', () => { await Promise.resolve(); }); - expect(archiveButtonFor('Unlocked normal')?.disabled).toBe(false); expect(inlineSessionAction('Unlocked normal', 'Pin')).toBeDefined(); expect(inlineSessionAction('Unlocked normal', 'Delete')).toBeUndefined(); const activeItems = await openSessionMenuItems('Unlocked normal'); expect(activeItems).toEqual(expect.arrayContaining(['Rename', 'Delete'])); - expect(archiveButtonFor('Unlocked pinned')?.disabled).toBe(false); + expect(activeItems).toContain('Archive'); expect(inlineSessionAction('Unlocked pinned', 'Unpin')).toBeDefined(); + expect(await openSessionMenuItems('Unlocked pinned')).toContain('Archive'); const archivedItems = await openSessionMenuItems('Unlocked archived'); expect(archivedItems).toEqual([ @@ -3157,12 +3168,13 @@ describe('WebShellSidebar non-primary archive', () => { ], }); - renderSidebar({ onError, sessionActions: INLINE_ARCHIVE_ACTIONS }); + renderSidebar({ + onError, + sessionActions: { inlineItems: ['pin'] }, + }); await expandWorkspace('other'); - const archiveButton = archiveButtonFor('Secondary error'); - expect(archiveButton).toBeDefined(); + await selectSessionMenuItem('Secondary error', 'Archive'); await act(async () => { - click(archiveButton!); await archiveSessionsData.mock.results.at(-1)?.value; await Promise.resolve(); }); @@ -3203,11 +3215,13 @@ describe('WebShellSidebar non-primary archive', () => { : [], ); - renderSidebar({ sessionActions: INLINE_ARCHIVE_ACTIONS }); + renderSidebar({ + sessionActions: { inlineItems: ['pin'] }, + }); await ensureWorkspaceExpanded('project'); await expandWorkspace('other'); - expect(archiveButtonFor('Primary active')).toBeDefined(); - expect(archiveButtonFor('Secondary active')).toBeUndefined(); + expect(await openSessionMenuItems('Primary active')).toContain('Archive'); + expect(sessionAction('Secondary active')).toBeUndefined(); await expandArchived(); expect( listWorkspaceSessions.mock.calls.some( @@ -3216,73 +3230,6 @@ describe('WebShellSidebar non-primary archive', () => { ).toBe(false); }); - it('disables the inline archive button while a session has a running turn', async () => { - active.sessions.push( - { - sessionId: 'primary-running', - workspaceCwd: '/tmp/project', - displayName: 'Primary running', - hasActivePrompt: true, - }, - { - sessionId: 'primary-idle', - workspaceCwd: '/tmp/project', - displayName: 'Primary idle', - }, - ); - - renderSidebar({ sessionActions: INLINE_ARCHIVE_ACTIONS }); - await ensureWorkspaceExpanded('project'); - - const runningArchive = archiveButtonFor('Primary running'); - expect(runningArchive?.disabled).toBe(true); - expect(runningArchive?.title).toBe( - 'A running session cannot be archived; archiving would end its turn', - ); - expect(archiveButtonFor('Primary idle')?.disabled).toBe(false); - - await act(async () => { - click(runningArchive!); - await Promise.resolve(); - }); - expect(active.archiveSession).not.toHaveBeenCalled(); - }); - - it('refuses to archive a running session when the handler is invoked directly', async () => { - active.sessions.push({ - sessionId: 'primary-running', - workspaceCwd: '/tmp/project', - displayName: 'Primary running', - hasActivePrompt: true, - }); - - renderSidebar({ sessionActions: INLINE_ARCHIVE_ACTIONS }); - await ensureWorkspaceExpanded('project'); - - const runningArchive = archiveButtonFor('Primary running'); - expect(runningArchive).toBeDefined(); - // A stale render could leave the button enabled after the turn starts, so - // bypass DOM disabled suppression and invoke the React handler itself. - const propsKey = Object.keys(runningArchive!).find((key) => - key.startsWith('__reactProps$'), - ); - const onClick = propsKey - ? ( - runningArchive as unknown as Record< - string, - { onClick?: () => void } | undefined - > - )[propsKey]?.onClick - : undefined; - expect(onClick).toBeDefined(); - - await act(async () => { - onClick!(); - await Promise.resolve(); - }); - expect(active.archiveSession).not.toHaveBeenCalled(); - }); - it('disables the archive menu item while a session has a running turn', async () => { active.sessions.push({ sessionId: 'primary-running', @@ -3333,9 +3280,11 @@ describe('WebShellSidebar non-primary archive', () => { : [], ); - renderSidebar({ sessionActions: INLINE_ARCHIVE_ACTIONS }); + renderSidebar(); await expandWorkspace('other'); - expect(archiveButtonFor('Secondary active')).toBeUndefined(); + expect(await openSessionMenuItems('Secondary active')).not.toContain( + 'Archive', + ); expect(container.textContent).not.toContain('Archived'); }); @@ -3400,7 +3349,7 @@ describe('WebShellSidebar non-primary archive', () => { 'export', 'delete', ], - inlineItems: ['rename', 'pin', 'archive', 'export', 'delete'], + inlineItems: ['rename', 'pin', 'export', 'delete'], }, }); @@ -3514,17 +3463,19 @@ describe('WebShellSidebar non-primary archive', () => { errors: [], }); - renderSidebar({ onError, sessionActions: INLINE_ARCHIVE_ACTIONS }); + renderSidebar({ + onError, + sessionActions: { inlineItems: ['pin'] }, + }); await ensureWorkspaceExpanded('project'); await expandWorkspace('other'); - const primaryArchiveButton = archiveButtonFor('Legacy primary shared'); - const archiveButton = archiveButtonFor('Secondary shared'); - expect(primaryArchiveButton).toBeDefined(); - expect(primaryArchiveButton?.disabled).toBe(true); - expect(archiveButton).toBeDefined(); - expect(archiveButton?.disabled).toBe(false); + expect( + ( + await openSessionMenuItem('Legacy primary shared', 'Archive') + ).getAttribute('aria-disabled'), + ).toBe('true'); + await selectSessionMenuItem('Secondary shared', 'Archive'); await act(async () => { - click(archiveButton!); await archiveSessionsData.mock.results.at(-1)?.value; await Promise.resolve(); }); @@ -3564,19 +3515,23 @@ describe('WebShellSidebar non-primary archive', () => { : [], ); - renderSidebar({ sessionActions: INLINE_ARCHIVE_ACTIONS }); + renderSidebar({ + sessionActions: { inlineItems: ['pin'] }, + }); await ensureWorkspaceExpanded('project'); await expandWorkspace('other'); - const secondaryArchive = archiveButtonFor('Secondary pending'); - expect(secondaryArchive).toBeDefined(); + await selectSessionMenuItem('Secondary pending', 'Archive'); - await act(async () => { - click(secondaryArchive!); - await Promise.resolve(); - }); - - expect(archiveButtonFor('Secondary pending')?.disabled).toBe(true); - expect(archiveButtonFor('Primary pending')?.disabled).toBe(false); + expect( + (await openSessionMenuItem('Secondary pending', 'Archive')).getAttribute( + 'aria-disabled', + ), + ).toBe('true'); + expect( + (await openSessionMenuItem('Primary pending', 'Archive')).hasAttribute( + 'data-disabled', + ), + ).toBe(false); await act(async () => { finishArchive({ @@ -5114,19 +5069,20 @@ describe('WebShellSidebar session toolbar archive action dedupe', () => { ).filter((item) => item.textContent?.includes('Archive')).length; } - it('keeps one inline archive action when ownership changes', async () => { + it('keeps one archive menu item when ownership changes', async () => { enableOrganization(); useWorkspaceSessionCatalog(pinnedSecondaryCatalog); - renderSidebar({ sessionActions: INLINE_ARCHIVE_ACTIONS }); + renderSidebar(); await settle(); expect(inlineSessionAction('Pinned secondary', 'Unpin')).toBeDefined(); - expect(archiveButtonsInRow('Pinned secondary')).toHaveLength(1); + expect(archiveButtonsInRow('Pinned secondary')).toHaveLength(0); expect( sessionRow('Pinned secondary').querySelectorAll( '[class*="sessionActions"]', ), ).toHaveLength(1); + expect(await countArchiveMenuItemsInRow('Pinned secondary')).toBe(1); }); it('keeps archive when the read-only cluster is the sole owner', async () => { @@ -5142,38 +5098,25 @@ describe('WebShellSidebar session toolbar archive action dedupe', () => { : [], ); renderSidebar({ - sessionActions: { items: ['archive'], inlineItems: ['archive'] }, + sessionActions: { items: ['archive'] }, }); await expandWorkspace('other'); - expect(archiveButtonsInRow('Secondary only')).toHaveLength(1); + expect(archiveButtonsInRow('Secondary only')).toHaveLength(0); expect( sessionRow('Secondary only').querySelectorAll( '[class*="sessionActions"]', ), ).toHaveLength(1); + expect(await countArchiveMenuItemsInRow('Secondary only')).toBe(1); }); - it('keeps archive out of the inline slot by default', async () => { + it('keeps exactly one archive menu item by default', async () => { enableOrganization(); useWorkspaceSessionCatalog(pinnedSecondaryCatalog); renderSidebar(); await settle(); - expect(inlineSessionAction('Pinned secondary', 'Unpin')).toBeDefined(); - expect(archiveButtonsInRow('Pinned secondary')).toHaveLength(0); - expect(await countArchiveMenuItemsInRow('Pinned secondary')).toBe(1); - }); - - it('keeps exactly one archive menu item when archive is dropdown-only', async () => { - enableOrganization(); - useWorkspaceSessionCatalog(pinnedSecondaryCatalog); - - renderSidebar({ - sessionActions: { inlineItems: ['pin'] }, - }); - await settle(); - expect(archiveButtonsInRow('Pinned secondary')).toHaveLength(0); expect( sessionRow('Pinned secondary').querySelectorAll( diff --git a/packages/web-shell/client/components/sidebar/session-action-visibility.test.ts b/packages/web-shell/client/components/sidebar/session-action-visibility.test.ts index 9055ff93639..0c5087d54d7 100644 --- a/packages/web-shell/client/components/sidebar/session-action-visibility.test.ts +++ b/packages/web-shell/client/components/sidebar/session-action-visibility.test.ts @@ -21,6 +21,7 @@ const DEFAULT_INLINE_ITEMS: readonly WebShellSidebarSessionInlineActionItem[] = /** Items that can never appear as inline buttons. */ const DROPDOWN_ONLY_ITEMS: readonly WebShellSidebarSessionActionItem[] = [ + 'archive', 'group', ]; diff --git a/packages/web-shell/client/components/ui/data-table.test.tsx b/packages/web-shell/client/components/ui/data-table.test.tsx new file mode 100644 index 00000000000..3100498a58d --- /dev/null +++ b/packages/web-shell/client/components/ui/data-table.test.tsx @@ -0,0 +1,241 @@ +// @vitest-environment jsdom +import { useMemo, type ReactNode } from 'react'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { + type ColumnDef, + getCoreRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { describe, expect, it } from 'vitest'; + +import { DataTable, type DataTableColumnMeta } from './data-table'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +interface Item { + sized: string; + automatic: string; +} + +function TableHarness() { + const columns = useMemo[]>( + () => [ + { + accessorKey: 'sized', + header: 'Sized', + meta: { width: 200 } satisfies DataTableColumnMeta, + }, + { accessorKey: 'automatic', header: 'Automatic' }, + ], + [], + ); + const table = useReactTable({ + data: [{ sized: 'Sized', automatic: 'Automatic' }], + columns, + getCoreRowModel: getCoreRowModel(), + }); + return ; +} + +function FixedTableHarness() { + const columns = useMemo[]>( + () => [ + { + accessorKey: 'sized', + header: 'First', + meta: { + fixed: 'left', + width: 100, + fluidWeight: 1, + } satisfies DataTableColumnMeta, + }, + { + accessorKey: 'automatic', + header: 'Second', + meta: { + fixed: 'left', + fixedWidth: true, + width: 100, + } satisfies DataTableColumnMeta, + }, + { + id: 'remaining', + header: 'Remaining', + cell: () => 'Remaining', + meta: { width: 100, fluidWeight: 1 } satisfies DataTableColumnMeta, + }, + ], + [], + ); + const table = useReactTable({ + data: [{ sized: 'First', automatic: 'Second' }], + columns, + getCoreRowModel: getCoreRowModel(), + }); + return ; +} + +function FluidWeightTableHarness() { + const columns = useMemo[]>( + () => [ + { + accessorKey: 'sized', + header: 'One', + meta: { width: 100, fluidWeight: 1 } satisfies DataTableColumnMeta, + }, + { + accessorKey: 'automatic', + header: 'Three', + meta: { width: 100, fluidWeight: 3 } satisfies DataTableColumnMeta, + }, + ], + [], + ); + const table = useReactTable({ + data: [{ sized: 'One', automatic: 'Three' }], + columns, + getCoreRowModel: getCoreRowModel(), + }); + return ; +} + +function NarrowFixedTableHarness() { + const columns = useMemo[]>( + () => [ + { + accessorKey: 'sized', + header: 'Left', + meta: { + fixed: 'left', + fixedWidth: true, + width: 200, + } satisfies DataTableColumnMeta, + }, + { + accessorKey: 'automatic', + header: 'Middle', + meta: { width: 100 } satisfies DataTableColumnMeta, + }, + { + id: 'right', + header: 'Right', + cell: () => 'Right', + meta: { + fixed: 'right', + fixedWidth: true, + width: 200, + } satisfies DataTableColumnMeta, + }, + ], + [], + ); + const table = useReactTable({ + data: [{ sized: 'Left', automatic: 'Middle' }], + columns, + getCoreRowModel: getCoreRowModel(), + }); + return ; +} + +function renderAtWidth(element: ReactNode, width: number) { + let resize: ResizeObserverCallback = () => undefined; + const originalResizeObserver = globalThis.ResizeObserver; + globalThis.ResizeObserver = class { + constructor(callback: ResizeObserverCallback) { + resize = callback; + } + observe() {} + unobserve() {} + disconnect() {} + } as typeof ResizeObserver; + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => root.render(element)); + const scroller = container.querySelector( + '[data-slot="table-container"]', + ) as HTMLElement; + Object.defineProperties(scroller, { + clientWidth: { configurable: true, value: width }, + scrollWidth: { configurable: true, value: width }, + }); + act(() => resize([], {} as ResizeObserver)); + return { + container, + cleanup: () => { + act(() => root.unmount()); + container.remove(); + globalThis.ResizeObserver = originalResizeObserver; + }, + }; +} + +describe('DataTable', () => { + it('keeps vertical scrolling outside the horizontal table scroller', () => { + const { container, cleanup } = renderAtWidth(, 600); + try { + expect( + container.querySelector('[data-slot="table-container"]')?.className, + ).toContain('overflow-y-hidden'); + } finally { + cleanup(); + } + }); + + it('leaves remaining fluid width to columns without an explicit width', () => { + const { container, cleanup } = renderAtWidth(, 600); + try { + const columns = container.querySelectorAll('col'); + expect(columns[0]!.style.width).toBe('200px'); + expect(columns[1]!.style.width).toBe(''); + } finally { + cleanup(); + } + }); + + it('offsets fixed columns by the resolved width of preceding columns', () => { + const { container, cleanup } = renderAtWidth(, 600); + try { + const headers = container.querySelectorAll('th'); + expect(headers[0]!.style.width).toBe('250px'); + expect(headers[1]!.style.width).toBe('100px'); + expect(headers[1]!.style.left).toBe('250px'); + } finally { + cleanup(); + } + }); + + it('distributes extra width by fluidWeight', () => { + const { container, cleanup } = renderAtWidth( + , + 600, + ); + try { + const headers = container.querySelectorAll('th'); + expect(headers[0]!.style.width).toBe('200px'); + expect(headers[1]!.style.width).toBe('400px'); + } finally { + cleanup(); + } + }); + + it('unpins right columns before fixed regions overlap', () => { + const { container, cleanup } = renderAtWidth( + , + 350, + ); + try { + const headers = container.querySelectorAll('th'); + expect(headers[0]!.className).toContain('sticky'); + expect(headers[0]!.style.left).toBe('0px'); + expect(headers[2]!.className).not.toContain('sticky'); + expect(headers[2]!.style.right).toBe(''); + const cells = container.querySelectorAll('td'); + expect(cells[2]!.className).not.toContain('sticky'); + expect(cells[2]!.style.right).toBe(''); + } finally { + cleanup(); + } + }); +}); diff --git a/packages/web-shell/client/components/ui/data-table.tsx b/packages/web-shell/client/components/ui/data-table.tsx new file mode 100644 index 00000000000..193450526b3 --- /dev/null +++ b/packages/web-shell/client/components/ui/data-table.tsx @@ -0,0 +1,507 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { ComponentProps, CSSProperties, ReactNode } from 'react'; +import { + flexRender, + type Row, + type Table as TanStackTable, +} from '@tanstack/react-table'; + +import { cn } from '@/lib/utils'; +import { Button } from './button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from './select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from './table'; +import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip'; + +export interface DataTableColumnMeta { + headerClassName?: string; + width?: number; + fixedWidth?: boolean; + fluidWeight?: number; + fixed?: 'left' | 'right'; + fixedOffset?: number; + fixedEdge?: boolean; + stopRowClick?: boolean; + truncate?: boolean | ((row: TData) => boolean); + tooltip?: (row: TData) => ReactNode; +} + +function columnMeta(column: { + columnDef: { meta?: unknown }; +}): DataTableColumnMeta { + return (column.columnDef.meta ?? {}) as DataTableColumnMeta; +} + +function fixedClassName( + meta: DataTableColumnMeta, + header: boolean, + showLeftShadow: boolean, + showRightShadow: boolean, + fixedEnabled: boolean, +): string | undefined { + if (!meta.fixed || !fixedEnabled) return undefined; + const showShadow = + meta.fixedEdge && + (meta.fixed === 'left' ? showLeftShadow : showRightShadow); + return cn( + 'sticky bg-background transition-colors', + header + ? 'z-20' + : 'z-10 group-hover:bg-[color-mix(in_srgb,var(--muted)_50%,var(--background))] group-data-[state=selected]:bg-muted', + meta.fixedEdge && + "after:pointer-events-none after:absolute after:top-0 after:-bottom-px after:w-[30px] after:content-[''] after:transition-shadow", + meta.fixedEdge && + (meta.fixed === 'left' ? 'after:left-full' : 'after:right-full'), + showShadow && + (meta.fixed === 'left' + ? 'after:shadow-[inset_10px_0_8px_-8px_var(--border)]' + : 'after:shadow-[inset_-10px_0_8px_-8px_var(--border)]'), + ); +} + +function columnWidthStyle( + meta: DataTableColumnMeta, + fluidLayout: boolean, + availableWidth: number, + minimumWidth: number, + fluidWeight: number, +): CSSProperties | undefined { + const width = resolvedColumnWidth( + meta, + fluidLayout, + availableWidth, + minimumWidth, + fluidWeight, + ); + if (width === undefined || meta.width === undefined) return undefined; + return { + width, + minWidth: meta.width, + maxWidth: fluidLayout && !meta.fixedWidth ? 'none' : meta.width, + }; +} + +function resolvedColumnWidth( + meta: DataTableColumnMeta, + fluidLayout: boolean, + availableWidth: number, + minimumWidth: number, + fluidWeight: number, +): number | undefined { + if (meta.width === undefined) return undefined; + if (!fluidLayout || meta.fixedWidth || fluidWeight === 0) return meta.width; + const weight = meta.fluidWeight ?? meta.width; + return ( + meta.width + + Math.max(0, availableWidth - minimumWidth) * (weight / fluidWeight) + ); +} + +function columnStyle( + meta: DataTableColumnMeta, + fluidLayout: boolean, + availableWidth: number, + minimumWidth: number, + fluidWeight: number, + fixedOffset?: number, + fixedEnabled = true, +): CSSProperties | undefined { + const style = + columnWidthStyle( + meta, + fluidLayout, + availableWidth, + minimumWidth, + fluidWeight, + ) ?? {}; + if (meta.fixed && fixedEnabled) { + style[meta.fixed] = fixedOffset ?? meta.fixedOffset ?? 0; + } + return Object.keys(style).length > 0 ? style : undefined; +} + +export function DataTable({ + table, + className, + emptyContent, + onRowClick, + rowClassName, + ...props +}: Omit< + ComponentProps<'div'>, + 'children' | 'onClick' | 'onScrollCapture' | 'ref' +> & { + table: TanStackTable; + emptyContent?: ReactNode; + onRowClick?: (row: Row) => void; + rowClassName?: string | ((row: Row) => string | undefined); +}) { + const viewportRef = useRef(null); + const rowCount = table.getRowModel().rows.length; + const visibleColumns = table.getVisibleLeafColumns(); + const widthMetrics = visibleColumns.reduce( + (metrics, column) => { + const meta = columnMeta(column); + metrics.minimum += meta.width ?? 0; + if (!meta.fixedWidth && meta.width !== undefined) { + metrics.fluidWeight += meta.fluidWeight ?? meta.width; + } + return metrics; + }, + { minimum: 0, fluidWeight: 0 }, + ); + const [fluidLayout, setFluidLayout] = useState(false); + const [availableWidth, setAvailableWidth] = useState(0); + const [fixedShadows, setFixedShadows] = useState({ + left: false, + right: false, + }); + const distributeFluidWidth = + fluidLayout && + visibleColumns.every( + (column) => columnMeta(column).width !== undefined, + ); + const resolvedWidths = new Map( + visibleColumns.map((column) => [ + column.id, + resolvedColumnWidth( + columnMeta(column), + distributeFluidWidth, + availableWidth, + widthMetrics.minimum, + widthMetrics.fluidWeight, + ) ?? 0, + ]), + ); + const fixedOffsets = new Map(); + let leftOffset = 0; + for (const column of visibleColumns) { + const meta = columnMeta(column); + if (meta.fixed !== 'left') continue; + const offset = meta.fixedOffset ?? leftOffset; + fixedOffsets.set(column.id, offset); + leftOffset = Math.max( + leftOffset, + offset + (resolvedWidths.get(column.id) ?? 0), + ); + } + let rightOffset = 0; + for (const column of [...visibleColumns].reverse()) { + const meta = columnMeta(column); + if (meta.fixed !== 'right') continue; + const offset = meta.fixedOffset ?? rightOffset; + fixedOffsets.set(column.id, offset); + rightOffset = Math.max( + rightOffset, + offset + (resolvedWidths.get(column.id) ?? 0), + ); + } + const leftFixedWidth = visibleColumns.reduce( + (width, column) => + columnMeta(column).fixed === 'left' + ? width + (resolvedWidths.get(column.id) ?? 0) + : width, + 0, + ); + const rightFixedWidth = visibleColumns.reduce( + (width, column) => + columnMeta(column).fixed === 'right' + ? width + (resolvedWidths.get(column.id) ?? 0) + : width, + 0, + ); + const pinRightColumns = + availableWidth === 0 || availableWidth > leftFixedWidth + rightFixedWidth; + + const updateLayout = useCallback( + (scroller: HTMLElement) => { + setAvailableWidth(scroller.clientWidth); + setFluidLayout(scroller.clientWidth >= widthMetrics.minimum); + const next = { + left: scroller.scrollLeft > 0, + right: + scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1, + }; + setFixedShadows((current) => + current.left === next.left && current.right === next.right + ? current + : next, + ); + }, + [widthMetrics.minimum], + ); + + useEffect(() => { + const viewport = viewportRef.current; + const scroller = viewport?.querySelector( + '[data-slot="table-container"]', + ); + if (!viewport || !scroller) return; + const update = () => updateLayout(scroller); + update(); + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(update); + observer.observe(scroller); + const renderedTable = scroller.querySelector('[data-slot="table"]'); + if (renderedTable) observer.observe(renderedTable); + return () => observer.disconnect(); + }, [rowCount, updateLayout]); + + return ( +
{ + const target = event.target as HTMLElement; + if (target.dataset.slot === 'table-container') { + updateLayout(target); + } + }} + > + + + {table.getVisibleLeafColumns().map((column) => ( + (column), + distributeFluidWidth, + availableWidth, + widthMetrics.minimum, + widthMetrics.fluidWeight, + )} + /> + ))} + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = columnMeta(header.column); + const sorted = header.column.getIsSorted(); + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {rowCount === 0 && emptyContent !== undefined && ( + + + {emptyContent} + + + )} + {table.getRowModel().rows.map((row) => ( + onRowClick(row) : undefined} + > + {row.getVisibleCells().map((cell) => { + const meta = columnMeta(cell.column); + const tooltip = meta.tooltip?.(row.original); + const content = flexRender( + cell.column.columnDef.cell, + cell.getContext(), + ); + const truncate = + typeof meta.truncate === 'function' + ? meta.truncate(row.original) + : meta.truncate !== false; + const constrained = + meta.width !== undefined && !meta.fixedWidth && truncate; + return ( + event.stopPropagation() + : undefined + } + > + {tooltip !== undefined && tooltip !== null ? ( + + +
+ {content} +
+
+ {tooltip} +
+ ) : constrained ? ( +
{content}
+ ) : ( + content + )} +
+ ); + })} +
+ ))} +
+
+
+ ); +} + +export function DataTablePagination({ + table, + pageSizes, + labels, + onPageSizeChange, +}: { + table: TanStackTable; + pageSizes: readonly number[]; + labels: { + rowsPerPage: string; + previous: ReactNode; + next: ReactNode; + page: (current: number, total: number) => ReactNode; + }; + onPageSizeChange?: (pageSize: number) => void; +}) { + return ( +
+ + {labels.rowsPerPage} + + + + + {labels.page( + table.getState().pagination.pageIndex + 1, + table.getPageCount(), + )} + + +
+ ); +} diff --git a/packages/web-shell/client/components/ui/react18-ref-compat.test.tsx b/packages/web-shell/client/components/ui/react18-ref-compat.test.tsx index ca797a79c63..82f76271ca2 100644 --- a/packages/web-shell/client/components/ui/react18-ref-compat.test.tsx +++ b/packages/web-shell/client/components/ui/react18-ref-compat.test.tsx @@ -13,6 +13,22 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger } from './dropdown-menu'; import { Input } from './input'; import { PopoverAnchor, PopoverContent, PopoverTrigger } from './popover'; import { SelectTrigger } from './select'; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from './table'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from './tooltip'; const FORWARD_REF_TYPE = Symbol.for('react.forward_ref'); @@ -35,6 +51,16 @@ describe('React 18 ref compatibility', () => { ['DropdownMenuTrigger', DropdownMenuTrigger], ['DropdownMenuSubTrigger', DropdownMenuSubTrigger], ['SelectTrigger', SelectTrigger], + ['Table', Table], + ['TableHeader', TableHeader], + ['TableBody', TableBody], + ['TableFooter', TableFooter], + ['TableRow', TableRow], + ['TableHead', TableHead], + ['TableCell', TableCell], + ['TableCaption', TableCaption], + ['TooltipTrigger', TooltipTrigger], + ['TooltipContent', TooltipContent], ])('%s forwards refs', (_name, Component) => { expect(Component).toHaveProperty('$$typeof', FORWARD_REF_TYPE); }); @@ -64,4 +90,62 @@ describe('React 18 ref compatibility', () => { act(() => root.unmount()); container.remove(); }); + + it('forwards a Table ref to its DOM element', () => { + const ref = React.createRef(); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => root.render()); + expect(ref.current).toBeInstanceOf(HTMLTableElement); + + act(() => root.unmount()); + container.remove(); + }); + + it('forwards an asChild TooltipTrigger ref to its DOM element', () => { + const ref = React.createRef(); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => + root.render( + + + + + + + , + ), + ); + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + + act(() => root.unmount()); + container.remove(); + }); + + it('forwards a TooltipContent ref to its DOM element', () => { + const ref = React.createRef(); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => + root.render( + + + Trigger + Content + + , + ), + ); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + + act(() => root.unmount()); + container.remove(); + }); }); diff --git a/packages/web-shell/client/components/ui/table.tsx b/packages/web-shell/client/components/ui/table.tsx new file mode 100644 index 00000000000..b21a5a933cc --- /dev/null +++ b/packages/web-shell/client/components/ui/table.tsx @@ -0,0 +1,146 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Table = React.forwardRef< + HTMLTableElement, + React.ComponentPropsWithoutRef<'table'> +>(function Table({ className, ...props }, ref) { + return ( +
+
+ + ); +}); + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.ComponentPropsWithoutRef<'thead'> +>(function TableHeader({ className, ...props }, ref) { + return ( + + ); +}); + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.ComponentPropsWithoutRef<'tbody'> +>(function TableBody({ className, ...props }, ref) { + return ( + + ); +}); + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.ComponentPropsWithoutRef<'tfoot'> +>(function TableFooter({ className, ...props }, ref) { + return ( + tr]:last:border-b-0', + className, + )} + {...props} + /> + ); +}); + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.ComponentPropsWithoutRef<'tr'> +>(function TableRow({ className, ...props }, ref) { + return ( + + ); +}); + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ComponentPropsWithoutRef<'th'> +>(function TableHead({ className, ...props }, ref) { + return ( +
+ ); +}); + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.ComponentPropsWithoutRef<'td'> +>(function TableCell({ className, ...props }, ref) { + return ( + + ); +}); + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.ComponentPropsWithoutRef<'caption'> +>(function TableCaption({ className, ...props }, ref) { + return ( +
+ ); +}); + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/packages/web-shell/client/components/ui/tooltip.tsx b/packages/web-shell/client/components/ui/tooltip.tsx index 643b8cd60ac..385022a2457 100644 --- a/packages/web-shell/client/components/ui/tooltip.tsx +++ b/packages/web-shell/client/components/ui/tooltip.tsx @@ -1,4 +1,4 @@ -import type * as React from 'react'; +import * as React from 'react'; import { Tooltip as TooltipPrimitive } from 'radix-ui'; import { cn } from '@/lib/utils'; @@ -23,26 +23,35 @@ function Tooltip({ return ; } -function TooltipTrigger({ - ...props -}: React.ComponentProps) { - return ; -} +const TooltipTrigger = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(function TooltipTrigger(props, ref) { + return ( + + ); +}); -function TooltipContent({ - className, - sideOffset = 0, - children, - ...props -}: React.ComponentProps) { +const TooltipContent = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(function TooltipContent( + { className, sideOffset = 0, children, ...props }, + ref, +) { const portalRoot = useWebShellPortalRoot(); return ( ); -} +}); export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }; diff --git a/packages/web-shell/client/constants/sessions.ts b/packages/web-shell/client/constants/sessions.ts index d359e9b2ffe..92b2f802120 100644 --- a/packages/web-shell/client/constants/sessions.ts +++ b/packages/web-shell/client/constants/sessions.ts @@ -11,6 +11,7 @@ export const SESSION_LIST_PAGE_SIZE = 1000; export const SIDEBAR_SESSION_PREVIEW_LIMIT = 5; export const SESSION_ORGANIZATION_FEATURE = 'session_organization'; +export const SESSION_LIVE_STATE_FEATURE = 'workspace_session_live_state'; export const SESSION_TRANSCRIPT_PAGINATION_FEATURE = 'session_transcript_pagination'; export const SESSION_MONITOR_TOOL_CORRELATION_FEATURE = diff --git a/packages/web-shell/client/hooks/useIsLargeScreen.ts b/packages/web-shell/client/hooks/useIsLargeScreen.ts index 97037dce9b2..033f10c9574 100644 --- a/packages/web-shell/client/hooks/useIsLargeScreen.ts +++ b/packages/web-shell/client/hooks/useIsLargeScreen.ts @@ -7,10 +7,8 @@ import { useEffect, useState } from 'react'; /** - * The width at or above which the app is considered a "large screen". The - * Session Overview panel is only offered past this point: below it there is no - * horizontal room to make managing several sessions at once worthwhile, and the - * sidebar collapses to a mobile drawer (see the 760px breakpoint in App.tsx). + * The width at or above which the app is considered a "large screen" for + * layouts such as split view. */ export const LARGE_SCREEN_QUERY = '(min-width: 1024px)'; diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index a49dc795a99..af5f968c369 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2724,15 +2724,16 @@ const EN: Messages = { 'thinking.closeTranslation': 'Close', 'thinking.inputTokens': (v) => `Input tokens: ${v?.count ?? '--'}`, 'thinking.outputTokens': (v) => `Output tokens: ${v?.count ?? '--'}`, - 'sessionsOverview.count': (v) => `${v?.count ?? 0} sessions`, 'sessionsOverview.current': 'Current', 'sessionsOverview.empty': 'No sessions yet', 'sessionsOverview.loadFailed': 'Failed to load sessions', 'sessionsOverview.loading': 'Loading sessions…', - 'sessionsOverview.openElsewhere': 'Currently open in another window', + 'sessionsOverview.noData': 'No data', 'sessionsOverview.openInSplit': 'Open in split', 'sessionsOverview.openInSplitHint': 'Show the selected sessions side by side in this window', + 'sessionsOverview.splitLimit': (v) => + `Select at most ${v?.max ?? 6} sessions to open them together`, 'sessionsOverview.openInTab': 'Open in new tab', 'sessionsOverview.openInTabHint': 'Open the selected sessions as a split view in a new browser tab', @@ -2740,10 +2741,48 @@ const EN: Messages = { 'Pop-up blocked. Allow pop-ups for this site to open sessions in new tabs.', 'sessionsOverview.refresh': 'Refresh', 'sessionsOverview.selectAll': 'Select all', - 'sessionsOverview.splitCap': (v) => - `Only the first ${v?.max ?? 6} selected sessions will open in the split.`, + 'sessionsOverview.titleColumn': 'Title', + 'sessionsOverview.sessionId': 'Session ID', + 'sessionsOverview.actions': 'Actions', + 'sessionsOverview.folder': 'Workspace', + 'sessionsOverview.time': 'Time', + 'sessionsOverview.worktree': 'Worktree', + 'sessionsOverview.selectedRows': (v) => + `${v?.count ?? 0} of ${v?.total ?? 0} row(s) selected.`, + 'sessionsOverview.previousPage': 'Previous', + 'sessionsOverview.nextPage': 'Next', + 'sessionsOverview.pageInfo': (v) => + `Page ${v?.page ?? 0} of ${v?.total ?? 0}`, + 'sessionsOverview.rowsPerPage': 'Rows per page', + 'sessionsOverview.workspaceFilter': 'Filter by workspace', + 'sessionsOverview.allWorkspaces': 'All', + 'sessionsOverview.searchPlaceholder': 'Search sessions…', + 'sessionsOverview.confirmArchiveTitle': 'Archive session?', + 'sessionsOverview.confirmArchive': (v) => + `"${v?.name ?? ''}" will be moved to archived sessions.`, + 'sessionsOverview.confirmArchiveBulkTitle': (v) => + `Archive ${v?.count ?? 0} sessions?`, + 'sessionsOverview.confirmArchiveBulk': (v) => + `${v?.count ?? 0} selected sessions will be moved to archived sessions.`, + 'sessionsOverview.confirmDeleteTitle': 'Delete session?', + 'sessionsOverview.confirmDelete': (v) => + `"${v?.name ?? ''}" and its conversation history will be permanently deleted. This cannot be undone.`, + 'sessionsOverview.deleteFailed': 'Failed to delete session', + 'sessionsOverview.archiveFailed': 'Failed to archive session', + 'sessionsOverview.actionUnavailable': + 'This action is unavailable for the selected session or workspace', + 'sessionsOverview.bulkArchive': 'Archive', + 'sessionsOverview.bulkArchiveHint': (v) => + `Archive ${v?.count ?? 0} selected sessions`, + 'sessionsOverview.bulkDelete': 'Delete', + 'sessionsOverview.bulkDeleteHint': (v) => + `Delete ${v?.count ?? 0} selected sessions`, + 'sessionsOverview.confirmDeleteBulkTitle': (v) => + `Delete ${v?.count ?? 0} sessions?`, + 'sessionsOverview.confirmDeleteBulk': (v) => + `${v?.count ?? 0} selected sessions and their conversation history will be permanently deleted. This cannot be undone.`, 'sessionsOverview.selectSession': (v) => `Select ${v?.name ?? ''}`, - 'sessionsOverview.status.idle': 'Idle', + 'sessionsOverview.status.askUserQuestion': 'User input needed', 'sessionsOverview.status.needsApproval': 'Needs approval', 'sessionsOverview.status.running': 'Running', 'sessionsOverview.title': 'Session Overview', @@ -5704,24 +5743,62 @@ const ZH: Messages = { 'thinking.outputTokens': (v) => `生成 Token:${v?.count ?? '--'}`, 'welcome.changeModel': '(/model 切换)', 'welcome.defaultModel': '未知模型', - 'sessionsOverview.count': (v) => `${v?.count ?? 0} 个会话`, 'sessionsOverview.current': '当前', 'sessionsOverview.empty': '暂无会话', 'sessionsOverview.loadFailed': '加载会话失败', 'sessionsOverview.loading': '正在加载会话…', - 'sessionsOverview.openElsewhere': '已在其他窗口打开', - 'sessionsOverview.openInSplit': '打开到分屏', + 'sessionsOverview.noData': '暂无数据', + 'sessionsOverview.openInSplit': '分屏打开', 'sessionsOverview.openInSplitHint': '在本窗口内并排显示选中的会话', - 'sessionsOverview.openInTab': '在新标签页打开', + 'sessionsOverview.splitLimit': (v) => + `最多选择 ${v?.max ?? 6} 个会话同时打开`, + 'sessionsOverview.openInTab': '新标签页打开', 'sessionsOverview.openInTabHint': '在新标签页中以分屏并排打开选中的会话', 'sessionsOverview.popupBlocked': '弹出窗口被拦截。请允许本站弹窗,以便在新标签页中打开会话。', 'sessionsOverview.refresh': '刷新', 'sessionsOverview.selectAll': '全选', - 'sessionsOverview.splitCap': (v) => - `只有前 ${v?.max ?? 6} 个选中的会话会进入分屏。`, + 'sessionsOverview.titleColumn': '标题', + 'sessionsOverview.sessionId': '会话 ID', + 'sessionsOverview.actions': '操作', + 'sessionsOverview.folder': '工作区', + 'sessionsOverview.time': '时间', + 'sessionsOverview.worktree': 'Worktree', + 'sessionsOverview.selectedRows': (v) => + `${v?.count ?? 0} / ${v?.total ?? 0} 行已选`, + 'sessionsOverview.previousPage': '上一页', + 'sessionsOverview.nextPage': '下一页', + 'sessionsOverview.pageInfo': (v) => + `第 ${v?.page ?? 0} / ${v?.total ?? 0} 页`, + 'sessionsOverview.rowsPerPage': '每页行数', + 'sessionsOverview.workspaceFilter': '按工作区筛选', + 'sessionsOverview.allWorkspaces': '全部', + 'sessionsOverview.searchPlaceholder': '搜索会话…', + 'sessionsOverview.confirmArchiveTitle': '归档会话?', + 'sessionsOverview.confirmArchive': (v) => + `“${v?.name ?? ''}” 将移至已归档会话。`, + 'sessionsOverview.confirmArchiveBulkTitle': (v) => + `归档 ${v?.count ?? 0} 个会话?`, + 'sessionsOverview.confirmArchiveBulk': (v) => + `选中的 ${v?.count ?? 0} 个会话将移至已归档会话。`, + 'sessionsOverview.confirmDeleteTitle': '删除会话?', + 'sessionsOverview.confirmDelete': (v) => + `"${v?.name ?? ''}" 及其对话记录将被永久删除,此操作无法撤销。`, + 'sessionsOverview.deleteFailed': '删除会话失败', + 'sessionsOverview.archiveFailed': '归档会话失败', + 'sessionsOverview.actionUnavailable': '所选会话或工作区不支持此操作', + 'sessionsOverview.bulkArchive': '归档', + 'sessionsOverview.bulkArchiveHint': (v) => + `归档选中的 ${v?.count ?? 0} 个会话`, + 'sessionsOverview.bulkDelete': '删除', + 'sessionsOverview.bulkDeleteHint': (v) => + `删除选中的 ${v?.count ?? 0} 个会话`, + 'sessionsOverview.confirmDeleteBulkTitle': (v) => + `删除 ${v?.count ?? 0} 个会话?`, + 'sessionsOverview.confirmDeleteBulk': (v) => + `选中的 ${v?.count ?? 0} 个会话及其对话记录将被永久删除,此操作无法撤销。`, 'sessionsOverview.selectSession': (v) => `选择 ${v?.name ?? ''}`, - 'sessionsOverview.status.idle': '空闲', + 'sessionsOverview.status.askUserQuestion': '需要用户输入', 'sessionsOverview.status.needsApproval': '待审批', 'sessionsOverview.status.running': '运行中', 'sessionsOverview.title': '会话总览', diff --git a/packages/web-shell/package.json b/packages/web-shell/package.json index 0749f7b51e1..2bb795366c1 100644 --- a/packages/web-shell/package.json +++ b/packages/web-shell/package.json @@ -43,6 +43,7 @@ "@datafe-open/markdown-chart-echarts": "^0.1.12", "@datafe-open/markdown-chart-react": "^0.1.12", "@modelcontextprotocol/ext-apps": "^1.7.5", + "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.26", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0",