diff --git a/docs/design/web-shell-session-overview.md b/docs/design/web-shell-session-overview.md new file mode 100644 index 00000000000..b2fc62f7d5c --- /dev/null +++ b/docs/design/web-shell-session-overview.md @@ -0,0 +1,66 @@ +# Web Shell session overview + +## Problem + +The overview ranks blocked sessions first, but permission requests, questions, +and running turns share the same spinner. Session IDs occupy a full column, +while long titles compete with workspace, branch, and action columns. Clicking +a title opens the session; clicking elsewhere in the same row selects it. +The sidebar already offers a richer session details popover, but the overview +uses only its Git-specific variant. + +## Design + +- Keep the existing session catalog, live-state subscriptions, workspace + identities, pagination, and mutation capability checks. +- Use a two-line session cell: title first, then workspace, branch, and PR. + Move the complete ID and path into the shared details popover. +- Show distinct permission, question, running, and idle states in the table. + Keep a compact active/attention cue beside the pinned title so narrow + layouts do not hide the row's state behind the pinned actions. + Add an all/needs-attention/running/idle filter and keep workspace selection + visible in the toolbar. Search titles, IDs, branches, and PR numbers. +- Reuse the sidebar details popover on title hover. Provide an explicit + details button for keyboard and touch users. Keep PR/issue links and ID + copying inside the popover, with events isolated from row navigation. + Only one overview details popover is open at a time. Long values wrap in + an internally scrolling surface; keyboard focus stays visible when it opens. + When hover replaces focused details, move focus to the incoming title + before opening its preview. Ordinary hover preserves an external input's + focus, including when portals live in a ShadowRoot, and Tab continues from + the row after the preview closes. +- Make the popover status agree with the overview's derived live state, + including older daemons that provide pending approvals via status reports. +- Open a session when its row or title is clicked. Checkboxes exclusively + control selection; existing rename/export/archive/delete controls keep their + behavior. Show batch actions only when a selection exists. + Dragging to select text does not navigate. Clicking a plain cell during an + inline rename preserves the draft; Enter saves and Escape cancels. + Sorting ends the rename and keeps keyboard focus on the sort header. + Starting rename closes details. If the edited row leaves the visible page, + discard the hidden draft so it cannot disable navigation or return later. + Preserve row state across the temporary empty page while a shrinking catalog + clamps pagination, then reconcile against the final visible identities. +- Keep the existing shared table and portal primitives. Popovers must stay + within the Web Shell boundary and preserve React 18 ref forwarding. + +## Scope + +The implementation stays in the Web Shell package: the overview, its styles +and tests, the shared session details popover and tests, and English/Chinese +messages. No daemon routes, SDK fields, permission changes, new dependencies, +or cross-package refactors are required. Idle does not imply task completion. + +## Verification + +Validate title/row navigation versus selection, search and filter reset rules, +status priority, full details, keyboard entry and Escape dismissal, ID copy, +portal event propagation, and existing cross-workspace mutation restrictions. +Run focused unit tests, the build/typecheck/bundle workflow, and independent +browser or test-script verification. Record baseline and post-change evidence +under `.qwen/e2e-tests/`. + +## Open questions + +None. The accepted prototype establishes the first iteration; archived-session +browsing and changes to mutation semantics remain outside this PR. diff --git a/packages/cli/src/agent-view/supervisor-runner.test.ts b/packages/cli/src/agent-view/supervisor-runner.test.ts index 71e0e3bb48b..ca438513a93 100644 --- a/packages/cli/src/agent-view/supervisor-runner.test.ts +++ b/packages/cli/src/agent-view/supervisor-runner.test.ts @@ -17,6 +17,7 @@ import { INTERNAL_AGENT_VIEW_SUPERVISOR_ARG, ensureAgentViewSupervisor, runAgentViewSupervisor, + type RunAgentViewSupervisorOptions, } from './supervisor-runner.js'; import type { AgentViewSupervisorHandler, @@ -31,8 +32,10 @@ import { const cleanupDirs: string[] = []; const cleanupServers: AgentViewSupervisorServerHandle[] = []; +const cleanupSupervisors: Array<() => Promise> = []; afterEach(async () => { + await Promise.all(cleanupSupervisors.splice(0).map((cleanup) => cleanup())); await Promise.allSettled( cleanupServers.splice(0).map((server) => server.close()), ); @@ -331,9 +334,9 @@ describe('Agent View supervisor runner', () => { it('closes the supervisor server when shutdown is requested', async () => { const { globalDir, socketPath } = await makeSupervisorPath(); - const supervisorPromise = runAgentViewSupervisor({ globalDir }); - - await waitForSupervisor(socketPath, globalDir); + const { supervisorPromise, authToken } = await runTestSupervisor({ + globalDir, + }); await expect(readAgentViewSupervisor({ globalDir })).resolves.toMatchObject( { pid: process.pid, @@ -342,7 +345,6 @@ describe('Agent View supervisor runner', () => { protocolVersion: 1, }, ); - const authToken = await readAuthToken(globalDir); await expect( callAgentViewSupervisor(socketPath, 'shutdown', undefined, { authToken, @@ -364,10 +366,9 @@ describe('Agent View supervisor runner', () => { it('does not remove metadata written by a replacement supervisor', async () => { const { globalDir, socketPath } = await makeSupervisorPath(); - const supervisorPromise = runAgentViewSupervisor({ globalDir }); - - await waitForSupervisor(socketPath, globalDir); - const authToken = await readAuthToken(globalDir); + const { supervisorPromise, authToken } = await runTestSupervisor({ + globalDir, + }); const replacement = { schemaVersion: 1 as const, pid: process.pid + 1, @@ -390,14 +391,12 @@ describe('Agent View supervisor runner', () => { it('auto-exits when maintenance sees only hibernated managed sessions', async () => { const { globalDir, socketPath } = await makeSupervisorPath(); - const supervisorPromise = runAgentViewSupervisor({ + const { supervisorPromise, authToken } = await runTestSupervisor({ globalDir, maintenanceIntervalMs: 10, hibernationPolicy: { autoExitGraceMs: 0 }, }); - await waitForSupervisor(socketPath, globalDir); - const authToken = await readAuthToken(globalDir); await writeHibernatedSessionForTest(globalDir, 'session-1'); await supervisorPromise; @@ -405,6 +404,43 @@ describe('Agent View supervisor runner', () => { }); }); +async function runTestSupervisor( + options: RunAgentViewSupervisorOptions & { globalDir: string }, +): Promise<{ + supervisorPromise: Promise; + authToken: string | undefined; +}> { + const socketPath = getAgentViewSupervisorSocketPath(options); + const supervisorPromise = runAgentViewSupervisor(options); + let settled = false; + let authToken: string | undefined = undefined; + // Observe startup failures immediately; cleanup still awaits the original + // promise so errors are reported before its directory can be removed. + void supervisorPromise.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + cleanupSupervisors.push(async () => { + await vi.waitFor( + async () => { + if (settled) return; + await callAgentViewSupervisor(socketPath, 'shutdown', undefined, { + authToken: authToken ?? (await readAuthToken(options.globalDir)), + timeoutMs: 100, + }); + }, + { timeout: 10_000, interval: 25 }, + ); + await supervisorPromise; + }); + authToken = await waitForSupervisor(socketPath, options.globalDir); + return { supervisorPromise, authToken }; +} + function createFakeSupervisor( socketPath: string, handler: AgentViewSupervisorHandler, @@ -461,19 +497,18 @@ async function writeHibernatedSessionForTest( async function waitForSupervisor( socketPath: string, globalDir: string, -): Promise { - for (let attempt = 0; attempt < 20; attempt++) { - try { +): Promise { + return vi.waitFor( + async () => { + const authToken = await readAuthToken(globalDir); await callAgentViewSupervisor(socketPath, 'status', undefined, { - authToken: await readAuthToken(globalDir), + authToken, timeoutMs: 100, }); - return; - } catch { - await delay(25); - } - } - throw new Error('Timed out waiting for test supervisor.'); + return authToken; + }, + { timeout: 5_000, interval: 25 }, + ); } async function readAuthToken(globalDir: string): Promise { diff --git a/packages/web-shell/client/components/SessionOverviewPanel.module.css b/packages/web-shell/client/components/SessionOverviewPanel.module.css index 53479a2d0fc..1ce03ca3fd8 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.module.css +++ b/packages/web-shell/client/components/SessionOverviewPanel.module.css @@ -44,6 +44,10 @@ background: color-mix(in srgb, var(--success-color) 14%, transparent); } +.attention { + color: var(--warning-color); +} + /* The running indicator mirrors the sidebar's session-loading spinner: only shown while a turn is active, so idle sessions stay visually quiet. */ .loading { diff --git a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx index 4b6f2ccd671..3fbf5f125ed 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx @@ -386,6 +386,14 @@ function setInputValue(input: HTMLInputElement, value: string): void { input.dispatchEvent(new Event('input', { bubbles: true })); } +function statusFilterButton(label: string): HTMLButtonElement { + return Array.from( + container!.querySelectorAll( + '[aria-label="Filter by session status"] button', + ), + ).find((button) => button.textContent?.startsWith(label))!; +} + describe('deriveSessionCards', () => { it('ranks needs-approval above user-input above running above idle, then by recency', () => { const sessions = [ @@ -514,6 +522,79 @@ describe('deriveSessionCards', () => { }); describe('SessionOverviewPanel', () => { + it.each(['FEATURE/TOPIC', '1234', '#1234', 'SESSION-SEARCH'])( + 'searches branch, PR and ID metadata: %s', + (query) => { + sessionsState.sessions = [ + session('session-search', { + displayName: 'Match', + branch: { name: 'feature/topic', baseBranch: 'main' }, + prs: [{ number: 1234, url: 'https://github.com/o/r/pull/1234' }], + }), + session('other', { displayName: 'Other' }), + ]; + render(); + act(() => setInputValue(container!.querySelector('input')!, query)); + expect(rowTitles()).toEqual(['Match']); + }, + ); + + it('groups approval and question sessions as needing attention and drops stale selections', () => { + sessionsState.sessions = [ + session('approval', { + displayName: 'Approval', + isWaitingForPermission: true, + }), + session('question', { + displayName: 'Question', + isWaitingForUserQuestion: true, + }), + session('running', { displayName: 'Run', hasActivePrompt: true }), + session('idle', { displayName: 'Idle' }), + ]; + render(); + act(() => click(statusFilterButton('Needs attention'))); + expect(rowTitles()).toEqual(['Approval', 'Question']); + expect( + statusFilterButton('Needs attention').getAttribute('aria-pressed'), + ).toBe('true'); + act(() => click(rowCheckbox(rows()[0]!))); + sessionsState.sessions = sessionsState.sessions.map((s) => + s.sessionId === 'approval' + ? { ...s, isWaitingForPermission: false, hasActivePrompt: true } + : s, + ); + rerender(); + expect(rowTitles()).toEqual(['Question']); + expect(footerButton('Open in new tab')).toBeNull(); + expect(statusFilterButton('Running').textContent).toContain('2'); + }); + + it('resets page and selection on status changes and combines them with search', () => { + window.localStorage.setItem( + 'qwen-web-shell-session-overview-page-size', + '10', + ); + sessionsState.sessions = [ + ...Array.from({ length: 12 }, (_, i) => + session(`run-${i}`, { displayName: `Run ${i}`, hasActivePrompt: true }), + ), + session('idle', { displayName: 'Idle match' }), + ]; + render(); + act(() => click(footerButton('Next')!)); + act(() => click(rowCheckbox(rows()[0]!))); + act(() => click(statusFilterButton('Idle'))); + expect(rowTitles()).toEqual(['Idle match']); + expect(container!.textContent).toContain('Page 1 of 1'); + expect(footerButton('Open in new tab')).toBeNull(); + act(() => setInputValue(container!.querySelector('input')!, 'Run')); + expect(container!.textContent).toContain('No data'); + act(() => click(statusFilterButton('Running'))); + expect(rowTitles()).toHaveLength(10); + expect(statusFilterButton('Idle').textContent).toContain('0'); + }); + it('renders an empty state when there are no sessions', () => { render(); const empty = container!.querySelector('[data-slot="data-table-empty"]'); @@ -545,134 +626,621 @@ describe('SessionOverviewPanel', () => { expect(rowTitles()).toEqual(['Charlie', 'Alpha', 'Bravo']); }); - it('shows loading after the title for every non-idle session', () => { + it('distinguishes actionable states and only spins for running turns', () => { sessionsState.sessions = [ session('s-run', { displayName: 'Run', hasActivePrompt: true }), session('s-appr', { displayName: 'Approval', isWaitingForPermission: true, + hasActivePrompt: true, }), session('s-q', { displayName: 'Question', isWaitingForUserQuestion: true, + hasActivePrompt: 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 states = rows().map((row) => + row.querySelector('[data-web-shell-session-status]'), + ); + expect(states.map((state) => state?.textContent)).toEqual([ + 'Needs approval', + 'User input needed', + 'Running', + 'Idle', + ]); + expect( + states.map( + (state) => !!state?.querySelector('[data-web-shell-session-loading]'), + ), + ).toEqual([false, false, true, false]); + expect( + states[0]?.querySelector('svg.lucide-shield-question-mark'), + ).not.toBeNull(); + expect( + states[1]?.querySelector('svg.lucide-circle-question-mark'), + ).not.toBeNull(); + expect(states[3]?.querySelector('svg.lucide-circle')).not.toBeNull(); + // The attention cue and the status column pick their icon twice; pin + // them to the same glyph so the mappings cannot drift apart. + for (const index of [0, 1]) { + const cueIcon = rows()[index]!.querySelector( + '[data-web-shell-session-status-cue] svg', + ); + expect(cueIcon?.outerHTML).toBe( + states[index]?.querySelector('svg')?.outerHTML, + ); } - const idle = rows().find((tr) => tr.textContent?.includes('Still'))!; - expect(idle.querySelector('[data-web-shell-session-loading]')).toBeNull(); + // An idle row renders no attention cue at all. + expect( + rows()[3]!.querySelector('[data-web-shell-session-status-cue]'), + ).toBeNull(); }); - it('toggles selection when the row is clicked', () => { - sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })]; + it('counts background work as running even without an active prompt', () => { + sessionsState.sessions = [ + session('s-bg', { + displayName: 'Background', + hasActivePrompt: false, + activeWorkState: 'active', + }), + session('s-idle', { displayName: 'Plain idle' }), + ]; render(); - act(() => click(rows()[0]!.querySelectorAll('td')[2] as HTMLElement)); - expect(rowCheckbox(rows()[0]!).getAttribute('data-state')).toBe('checked'); - expect(onOpenSession).not.toHaveBeenCalled(); + const state = rows()[0]!.querySelector('[data-web-shell-session-status]'); + expect(state?.textContent).toBe('Running'); + expect( + state?.querySelector('[data-web-shell-session-loading]'), + ).not.toBeNull(); + expect(statusFilterButton('Running').textContent).toContain('1'); + expect(statusFilterButton('Idle').textContent).toContain('1'); + // A session with live background work is not safe to archive or delete. + expect(rowActionButton(rows()[0]!, 'Archive').disabled).toBe(true); + expect(rowActionButton(rows()[0]!, 'Delete').disabled).toBe(true); + }); + it('opens the owning session on row click and selects only with the checkbox', () => { + sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })]; + render(); act(() => click(rows()[0]!.querySelectorAll('td')[2] as HTMLElement)); + expect(onOpenSession).toHaveBeenCalledExactlyOnceWith('s-run', '/w'); expect(rowCheckbox(rows()[0]!).getAttribute('data-state')).toBe( 'unchecked', ); + onOpenSession.mockClear(); + act(() => click(rowCheckbox(rows()[0]!))); + expect(rowCheckbox(rows()[0]!).getAttribute('data-state')).toBe('checked'); + expect(onOpenSession).not.toHaveBeenCalled(); + }); + + it('keeps text selection in the overview without navigating', () => { + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + render(); + const cell = rows()[0]!.querySelectorAll('td')[2]!; + const selection = window.getSelection()!; + const range = document.createRange(); + range.selectNodeContents(cell); + selection.addRange(range); + try { + act(() => click(cell)); + expect(onOpenSession).not.toHaveBeenCalled(); + const title = titleTrigger(rows()[0]!); + act(() => + title.dispatchEvent( + new MouseEvent('click', { bubbles: true, detail: 1 }), + ), + ); + expect(onOpenSession).not.toHaveBeenCalled(); + act(() => title.click()); + expect(onOpenSession).toHaveBeenCalledExactlyOnceWith('s1', '/w'); + onOpenSession.mockClear(); + } finally { + selection.removeAllRanges(); + } + act(() => click(cell)); + expect(onOpenSession).toHaveBeenCalledExactlyOnceWith('s1', '/w'); + }); + + it('keeps an inline rename draft when clicking a plain row cell', () => { + connectionState.sessionId = 's1'; + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + render(); + act(() => click(rowActionButton(rows()[0]!, 'Rename'))); + const input = container!.querySelector( + 'input[aria-label="Rename: One"]', + )!; + act(() => setInputValue(input, 'Renamed')); + // The editor keeps native mousedown so the caret can be placed. + const editorMouseDown = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + button: 0, + }); + act(() => input.dispatchEvent(editorMouseDown)); + expect(editorMouseDown.defaultPrevented).toBe(false); + // In-row controls get a prevented mousedown instead: no blur-cancel may + // remount the pressed node before mouseup, so their click still lands. + const actionMouseDown = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + button: 0, + }); + act(() => + rowActionButton(rows()[0]!, 'Rename').dispatchEvent(actionMouseDown), + ); + expect(actionMouseDown.defaultPrevented).toBe(true); + expect(input.value).toBe('Renamed'); + const cell = rows()[0]!.querySelectorAll('td')[2]!; + const mouseDown = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + }); + act(() => { + cell.dispatchEvent(mouseDown); + if (!mouseDown.defaultPrevented) input.blur(); + }); + expect(mouseDown.defaultPrevented).toBe(true); + act(() => click(cell)); + expect(onOpenSession).not.toHaveBeenCalled(); + expect(input.isConnected).toBe(true); + expect(input.value).toBe('Renamed'); + expect(document.activeElement).toBe(input); }); + it('keeps an inline rename draft when clicking the details button', () => { + connectionState.sessionId = 's1'; + sessionsState.sessions = [session('s1', { displayName: 'One' })]; + render(); + act(() => click(rowActionButton(rows()[0]!, 'Rename'))); + const input = container!.querySelector( + 'input[aria-label="Rename: One"]', + )!; + act(() => setInputValue(input, 'Renamed')); + const details = rowActionButton(rows()[0]!, 'Details for One'); + expect(details.disabled).toBe(true); + // Native activation: a click on the disabled button fires nothing, so + // the popover cannot steal focus and blur-cancel the draft. + act(() => details.click()); + expect(input.isConnected).toBe(true); + expect(input.value).toBe('Renamed'); + expect(document.activeElement).toBe(input); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + }); + + it.each([4, 11])( + 'ends rename and focuses the sort header with %i sessions', + async (count) => { + connectionState.sessionId = 's0'; + window.localStorage.setItem( + 'qwen-web-shell-session-overview-page-size', + '10', + ); + sessionsState.sessions = Array.from({ length: count }, (_, index) => + session(`s${index}`, { + displayName: `Session ${index}`, + updatedAt: new Date(Date.UTC(2026, 8, 20 - index)).toISOString(), + }), + ); + render(); + act(() => click(rowActionButton(rows()[0]!, 'Rename'))); + const input = container!.querySelector( + 'input[aria-label="Rename: Session 0"]', + )!; + act(() => setInputValue(input, 'Unsaved name')); + const sortButton = () => + container!.querySelector( + 'thead th:nth-child(4) button', + )!; + act(() => click(sortButton())); + await flushAsync(); + expect( + container!.querySelector('input[aria-label="Rename: Session 0"]'), + ).toBeNull(); + expect( + container! + .querySelector('thead th:nth-child(4)') + ?.getAttribute('aria-sort'), + ).toBe('ascending'); + expect(document.activeElement).toBe(sortButton()); + expect(rowTitles().includes('Session 0')).toBe(count === 4); + expect(workspaceActions.renameSession).not.toHaveBeenCalled(); + }, + ); + + it.each([false, true])( + 'keeps only one details popover when hovering after click (same row: %s)', + async (sameRow) => { + sessionsState.sessions = [ + session('a', { displayName: 'Alpha' }), + session('b', { displayName: 'Bravo' }), + ]; + vi.useFakeTimers(); + try { + render(); + await act(async () => + click(rowActionButton(rows()[0]!, 'Details for Alpha')), + ); + const target = titleTrigger(rows()[sameRow ? 0 : 1]!); + await act(async () => { + target.dispatchEvent(new Event('pointerover', { bubbles: true })); + vi.advanceTimersByTime(300); + }); + await act(async () => vi.advanceTimersByTime(0)); + const dialogs = document.querySelectorAll('[role="dialog"]'); + expect(dialogs).toHaveLength(1); + expect(dialogs[0]?.getAttribute('aria-label')).toBe( + sameRow ? 'Alpha' : 'Bravo', + ); + expect(dialogs[0]?.getAttribute('data-state')).toBe('open'); + expect(document.activeElement).toBe(target); + await act(async () => { + target.dispatchEvent(new Event('pointerout', { bubbles: true })); + vi.advanceTimersByTime(100); + }); + expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(0); + expect(document.activeElement).toBe(target); + expect(onOpenSession).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }, + ); + + it.each(['Escape', 'blur'])( + 'does not reopen hover details after cancelling rename with %s', + async (dismissal) => { + connectionState.sessionId = 'a'; + sessionsState.sessions = [session('a', { displayName: 'Alpha' })]; + vi.useFakeTimers(); + try { + render(); + const title = titleTrigger(rows()[0]!); + await act(async () => { + title.dispatchEvent(new Event('pointerover', { bubbles: true })); + vi.advanceTimersByTime(300); + }); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + await act(async () => { + title.dispatchEvent(new Event('pointerout', { bubbles: true })); + vi.advanceTimersByTime(50); + click(rowActionButton(rows()[0]!, 'Rename')); + }); + const input = container!.querySelector( + 'input[aria-label="Rename: Alpha"]', + )!; + expect(input).not.toBeNull(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + await act(async () => { + if (dismissal === 'blur') input.blur(); + else + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + }), + ); + }); + await act(async () => vi.advanceTimersByTime(300)); + expect( + container!.querySelector('input[aria-label="Rename: Alpha"]'), + ).toBeNull(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + } finally { + vi.useRealTimers(); + } + }, + ); + + it.each(['details', 'rename'])( + 'preserves %s when a catalog shrink clamps its still-visible row to the last page', + async (mode) => { + connectionState.sessionId = 's12'; + window.localStorage.setItem( + 'qwen-web-shell-session-overview-page-size', + '10', + ); + sessionsState.sessions = Array.from({ length: 15 }, (_, i) => + session(`s${i}`, { displayName: `Session ${i}` }), + ); + render(); + act(() => click(footerButton('Next')!)); + const row = rows().find( + (entry) => titleTrigger(entry).textContent === 'Session 12', + )!; + await act(async () => + click( + rowActionButton( + row, + mode === 'details' ? 'Details for Session 12' : 'Rename', + ), + ), + ); + if (mode === 'rename') + act(() => + setInputValue( + container!.querySelector( + 'input[aria-label="Rename: Session 12"]', + )!, + 'Preserved draft', + ), + ); + sessionsState.sessions = sessionsState.sessions.slice(5); + rerender(); + await flushAsync(); + expect(container!.textContent).toContain('Page 1 of 1'); + if (mode === 'details') { + expect(rowTitles()).toContain('Session 12'); + expect( + document.querySelector('[role="dialog"]')?.getAttribute('aria-label'), + ).toBe('Session 12'); + } else { + expect( + container!.querySelector( + 'input[aria-label="Rename: Session 12"]', + )?.value, + ).toBe('Preserved draft'); + } + }, + ); + + it.each(['filter', 'page'])( + 'cancels an invisible rename after a live update changes its %s', + async (mode) => { + connectionState.sessionId = 'target'; + window.localStorage.setItem( + 'qwen-web-shell-session-overview-page-size', + '10', + ); + const target = session('target', { + displayName: 'Target', + isWaitingForPermission: true, + }); + sessionsState.sessions = [ + session('question', { + displayName: 'Question', + isWaitingForUserQuestion: true, + }), + ...Array.from({ length: 9 }, (_, i) => session(`idle-${i}`)), + target, + ]; + render(); + if (mode === 'filter') + act(() => click(statusFilterButton('Needs attention'))); + const row = rows().find( + (entry) => titleTrigger(entry).textContent === 'Target', + )!; + act(() => click(rowActionButton(row, 'Rename'))); + act(() => + setInputValue( + container!.querySelector( + 'input[aria-label="Rename: Target"]', + )!, + 'Hidden draft', + ), + ); + sessionsState.sessions = sessionsState.sessions.map((entry) => + entry.sessionId === 'target' + ? { ...entry, isWaitingForPermission: false } + : entry, + ); + rerender(); + await flushAsync(); + expect( + container!.querySelector('input[aria-label="Rename: Target"]'), + ).toBeNull(); + const cell = rows()[0]!.querySelectorAll('td')[2]!; + const mouseDown = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + button: 0, + }); + act(() => cell.dispatchEvent(mouseDown)); + expect(mouseDown.defaultPrevented).toBe(false); + act(() => click(cell)); + expect(onOpenSession).toHaveBeenCalledExactlyOnceWith('question', '/w'); + sessionsState.sessions = sessionsState.sessions.map((entry) => + entry.sessionId === 'target' ? target : entry, + ); + rerender(); + await flushAsync(); + expect(rowTitles()).toContain('Target'); + expect( + container!.querySelector('input[aria-label="Rename: Target"]'), + ).toBeNull(); + expect(workspaceActions.renameSession).not.toHaveBeenCalled(); + }, + ); + + it.each(['filter', 'page'])( + 'closes details when live state removes its anchor through a %s', + async (mode) => { + window.localStorage.setItem( + 'qwen-web-shell-session-overview-page-size', + '10', + ); + const target = session('target', { + displayName: 'Target', + isWaitingForPermission: true, + }); + sessionsState.sessions = [ + ...Array.from({ length: 10 }, (_, i) => + session(`idle-${i}`, { displayName: `Idle ${i}` }), + ), + target, + ]; + render(); + if (mode === 'filter') + act(() => click(statusFilterButton('Needs attention'))); + const row = rows().find((candidate) => + candidate.textContent?.includes('Target'), + )!; + await act(async () => click(rowActionButton(row, 'Details for Target'))); + const dialog = document.querySelector('[role="dialog"]'); + expect(dialog).not.toBeNull(); + expect(dialog!.contains(document.activeElement)).toBe(true); + sessionsState.sessions = sessionsState.sessions.map((entry) => + entry.sessionId === 'target' + ? { ...entry, isWaitingForPermission: false } + : entry, + ); + rerender(); + await flushAsync(); + expect(rowTitles()).not.toContain('Target'); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + // The pinned popover held focus when its row left; the handoff must + // land on the panel, not or a detached node. + expect(document.activeElement).toBe( + container!.querySelector('[data-web-shell-session-panel]'), + ); + sessionsState.sessions = sessionsState.sessions.map((entry) => + entry.sessionId === 'target' ? target : entry, + ); + rerender(); + await flushAsync(); + expect(rowTitles()).toContain('Target'); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + }, + ); + it('keeps the title keyboard-focusable for opening a session', () => { sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })]; render(); const title = titleTrigger(rows()[0]!); expect(title.tagName).toBe('BUTTON'); act(() => click(title)); - expect(onOpenSession).toHaveBeenCalledWith('s-run', '/w'); + expect(onOpenSession).toHaveBeenCalledExactlyOnceWith('s-run', '/w'); expect(rowCheckbox(rows()[0]!).getAttribute('data-state')).toBe( 'unchecked', ); }); - it('shows the full title in a tooltip on hover', async () => { + it('shows full session details on title hover including compatibility approval state', async () => { sessionsState.sessions = [ - session('s1', { displayName: 'A long session title' }), + session('session-details', { + displayName: 'A long session title', + workspaceCwd: '/workspace/long/path', + branch: { name: 'feature/details', baseBranch: 'main' }, + prs: [ + { + number: 123, + url: 'https://github.com/o/r/pull/123', + issues: [ + { + number: 45, + url: 'https://github.com/o/r/issues/45', + state: 'open', + }, + ], + }, + ], + }), ]; + statusState.report = { + full: { + sessions: [ + statusSession('session-details', { + workspaceCwd: '/workspace/long/path', + pendingPermissionCount: 1, + }), + ], + }, + }; vi.useFakeTimers(); try { render(); await act(async () => { titleTrigger(rows()[0]!).dispatchEvent( - new Event('pointermove', { bubbles: true }), + new Event('pointerover', { bubbles: true }), ); - vi.advanceTimersByTime(300); - await Promise.resolve(); + vi.advanceTimersByTime(299); }); - 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(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); await act(async () => { - rows()[0]! - .querySelector('[data-web-shell-session-id]')! - .dispatchEvent(new Event('pointermove', { bubbles: true })); - vi.advanceTimersByTime(300); + vi.advanceTimersByTime(1); 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); + const details = document.querySelector('[role="dialog"]'); + for (const text of [ + 'A long session title', + '/workspace/long/path', + 'feature/details', + 'session-details', + 'Pull Request #123', + 'Issue #45', + 'Needs approval', + ]) { + expect(details?.textContent).toContain(text); + } + expect(onOpenSession).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); } }); - it('shows the full workspace path in a tooltip on hover', async () => { + it('uses compatibility status reports for running details', async () => { sessionsState.sessions = [ - session('s1', { workspaceCwd: '/workspace/with/a/long/path' }), + session('report-run', { displayName: 'Report run' }), ]; - 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(); - } + statusState.report = { + full: { + sessions: [statusSession('report-run', { hasActivePrompt: true })], + }, + }; + render(); + await act(async () => + click(rowActionButton(rows()[0]!, 'Details for Report run')), + ); + expect( + rows()[0]!.querySelector('[data-web-shell-session-status]')?.textContent, + ).toBe('Running'); + const dialog = document.querySelector('[role="dialog"]'); + expect(dialog?.textContent).toContain('Running'); + expect(dialog?.textContent).not.toContain('Idle'); + }); + + it('keeps the session ID out of the table and offers a details button', async () => { + sessionsState.sessions = [ + session('session-id-for-details', { displayName: 'Details' }), + ]; + render(); + expect(container!.querySelector('[data-web-shell-session-id]')).toBeNull(); + expect( + Array.from(container!.querySelectorAll('thead th')).map( + (header) => header.textContent, + ), + ).not.toContain('Session ID'); + await act(async () => + click(rowActionButton(rows()[0]!, 'Details for Details')), + ); + expect( + document.querySelector('[role="dialog"] [data-web-shell-session-id]') + ?.textContent, + ).toBe('session-id-for-details'); + expect(onOpenSession).not.toHaveBeenCalled(); + expect(rowCheckbox(rows()[0]!).getAttribute('data-state')).toBe( + 'unchecked', + ); }); - it('shows worktree metadata in a column immediately after the title', () => { + it('shows the full workspace path through the explicit details entry', async () => { + sessionsState.sessions = [ + session('s1', { + displayName: 'One', + workspaceCwd: '/workspace/with/a/long/path', + }), + ]; + render(); + await act(async () => + click(rowActionButton(rows()[0]!, 'Details for One')), + ); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + '/workspace/with/a/long/path', + ); + }); + + it('keeps workspace, branch and PR together below the session title', () => { sessionsState.sessions = [ session('s1', { displayName: 'One', @@ -691,100 +1259,80 @@ describe('SessionOverviewPanel', () => { ]; 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(headers.map((header) => header.textContent)).toEqual([ + '', + 'Title', + 'Status', + 'Time', + 'Actions', + ]); + const titleCell = titleTrigger(rows()[0]!).closest('td'); expect( - rows()[0]?.querySelector('[data-web-shell-session-git]')?.textContent, + titleCell?.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'); + titleCell?.querySelector('[data-web-shell-session-workspace]') + ?.textContent, + ).toBe('w'); expect( - rows()[0] - ?.querySelector('[data-web-shell-session-title]') - ?.closest('td') - ?.querySelector('a'), - ).toBeNull(); + titleCell + ?.querySelector('[data-web-shell-session-workspace]') + ?.getAttribute('title'), + ).toBe('/w'); 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(); + titleCell + ?.querySelector('[data-web-shell-session-git]') + ?.getAttribute('title'), + ).toBe('feature/a-very-long-branch-name'); + expect(titleCell?.querySelector('a')?.textContent).toBe('#123 +2'); + for (const selector of [ + '[data-web-shell-session-git]', + '[data-web-shell-session-workspace]', + ]) { + expect(titleCell?.querySelector(selector)?.className).toContain( + 'truncate', + ); } + expect(rows()[1]?.querySelector('[data-web-shell-session-git]')).toBeNull(); }); - 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(); - }); + it('does not open the session when a PR badge is clicked', async () => { + sessionsState.sessions = [ + session('s1', { + displayName: 'One', + prs: [{ number: 123, url: 'https://github.com/o/r/pull/123' }], + }), + ]; + render(); + const badge = rows()[0]!.querySelector('a')!; + await act(async () => + badge.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ), + ); + expect(onOpenSession).not.toHaveBeenCalled(); + expect(rowCheckbox(rows()[0]!).getAttribute('data-state')).toBe( + 'unchecked', + ); + }); - expect(document.querySelector('[role="dialog"]')).toBeNull(); - } finally { - vi.useRealTimers(); - } + it('provides details even when a session has no Git metadata', async () => { + sessionsState.sessions = [ + session('no-worktree', { displayName: 'No worktree' }), + ]; + render(); + await act(async () => + click(rowActionButton(rows()[0]!, 'Details for No worktree')), + ); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + 'no-worktree', + ); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + 'Idle', + ); }); - it('copies the session ID and restores the hover icon after two seconds', async () => { + it('copies the session ID from details without opening or selecting the row', async () => { vi.useFakeTimers(); const writeText = vi.fn(async () => {}); Object.defineProperty(navigator, 'clipboard', { @@ -792,30 +1340,27 @@ describe('SessionOverviewPanel', () => { value: { writeText }, }); try { - sessionsState.sessions = [session('session-to-copy')]; + sessionsState.sessions = [ + session('session-to-copy', { displayName: 'Copy' }), + ]; render(); - const copy = container!.querySelector( + await act(async () => + click(rowActionButton(rows()[0]!, 'Details for Copy')), + ); + const copy = document.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(); - + expect(copy.tabIndex).toBe(0); 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(onOpenSession).not.toHaveBeenCalled(); 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(); } @@ -838,7 +1383,7 @@ describe('SessionOverviewPanel', () => { await flushAsync(); const beta = rows().find((tr) => tr.textContent?.includes('Beta'))!; act(() => click(titleTrigger(beta))); - expect(onOpenSession).toHaveBeenCalledWith('b1', '/wsB'); + expect(onOpenSession).toHaveBeenCalledExactlyOnceWith('b1', '/wsB'); }); it('keeps equal session ids in different workspaces independent', async () => { @@ -888,24 +1433,25 @@ describe('SessionOverviewPanel', () => { expect(onCurrentSessionRemoved).not.toHaveBeenCalled(); }); - it('keeps footer actions visible and disables them until a row is selected', () => { + it('shows batch actions only while selected and keeps pagination available', () => { sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })]; 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); + const labels = ['Archive', 'Delete', 'Open in new tab', 'Open in split']; + expect(labels.map(footerButton)).toEqual([null, null, null, null]); + expect(container!.textContent).toContain('1 session(s)'); + expect(footerButton('Next')).not.toBeNull(); 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'); + for (const label of labels) { + const button = footerButton(label); + expect(button).not.toBeNull(); + expect(button!.disabled).toBe(false); + } + act(() => click(rowCheckbox(rows()[0]!))); + expect(labels.map(footerButton)).toEqual([null, null, null, null]); + expect(container!.textContent).toContain('1 session(s)'); + expect(footerButton('Next')).not.toBeNull(); }); it('opens the selected sessions as a split in ONE new tab (?split=…)', () => { @@ -1105,7 +1651,7 @@ describe('SessionOverviewPanel', () => { render(); const search = container!.querySelector( - 'input[aria-label="Search sessions…"]', + 'input[aria-label="Search title, branch, PR or ID…"]', ) as HTMLInputElement; expect(search.parentElement?.className).toContain('w-full'); expect(search.parentElement?.className).toContain('max-w-[300px]'); @@ -1206,7 +1752,7 @@ describe('SessionOverviewPanel', () => { primary.querySelector('[data-web-shell-session-workspace]')?.textContent, ).toBe('w'); expect( - container!.querySelector('button[aria-label="Filter by workspace"]'), + container!.querySelector('button[aria-label^="Filter by workspace:"]'), ).not.toBeNull(); }); @@ -1224,7 +1770,7 @@ describe('SessionOverviewPanel', () => { ]; render(); const input = container!.querySelector( - '[aria-label="Search sessions…"]', + '[aria-label="Search title, branch, PR or ID…"]', ) as HTMLInputElement; act(() => { setInputValue(input, 'Alpha'); @@ -1268,9 +1814,11 @@ describe('SessionOverviewPanel', () => { expect(rowTitles()).toContain('Alpha'); expect(rowTitles()).toContain('Beta'); const trigger = container!.querySelector( - 'button[aria-label="Filter by workspace"]', + 'button[aria-label^="Filter by workspace:"]', ) as HTMLElement; - expect(trigger.closest('th')?.textContent).toContain('Workspace'); + expect(trigger.closest('th')).toBeNull(); + expect(trigger.textContent).toContain('All workspaces'); + expect(trigger.getAttribute('aria-label')).toContain(trigger.textContent); expect(trigger.querySelector('.lucide-funnel')).not.toBeNull(); act(() => click(trigger)); const filterPanel = document.querySelector( @@ -1286,6 +1834,8 @@ describe('SessionOverviewPanel', () => { ) as HTMLElement; act(() => click(main)); expect(rowTitles()).toEqual(['Beta']); + expect(trigger.textContent).toContain('1/2 workspaces'); + expect(trigger.getAttribute('aria-label')).toContain(trigger.textContent); const payments = document.querySelector( '#session-overview-workspace-1', @@ -1295,7 +1845,7 @@ describe('SessionOverviewPanel', () => { container!.querySelector('[data-slot="data-table-empty"]')?.textContent, ).toContain('No data'); expect( - container!.querySelector('button[aria-label="Filter by workspace"]'), + container!.querySelector('button[aria-label^="Filter by workspace:"]'), ).not.toBeNull(); const all = document.querySelector( @@ -1328,7 +1878,7 @@ describe('SessionOverviewPanel', () => { ), ); expect( - container!.querySelector('button[aria-label="Filter by workspace"]'), + container!.querySelector('button[aria-label^="Filter by workspace:"]'), ).toBeNull(); }); @@ -1349,7 +1899,7 @@ describe('SessionOverviewPanel', () => { await flushAsync(); // Exclude /wsB through the funnel filter. const trigger = container!.querySelector( - 'button[aria-label="Filter by workspace"]', + 'button[aria-label^="Filter by workspace:"]', ) as HTMLElement; act(() => click(trigger)); const payments = document.querySelector( @@ -1376,7 +1926,7 @@ describe('SessionOverviewPanel', () => { ); await flushAsync(); expect( - container!.querySelector('button[aria-label="Filter by workspace"]'), + container!.querySelector('button[aria-label^="Filter by workspace:"]'), ).toBeNull(); expect(rowTitles()).toEqual(['Beta']); }); @@ -1398,7 +1948,7 @@ describe('SessionOverviewPanel', () => { await flushAsync(); // Exclude the primary workspace through the funnel filter. const trigger = container!.querySelector( - 'button[aria-label="Filter by workspace"]', + 'button[aria-label^="Filter by workspace:"]', ) as HTMLElement; act(() => click(trigger)); const main = document.querySelector( @@ -1422,7 +1972,7 @@ describe('SessionOverviewPanel', () => { rerender(); await flushAsync(); expect( - container!.querySelector('button[aria-label="Filter by workspace"]'), + container!.querySelector('button[aria-label^="Filter by workspace:"]'), ).toBeNull(); expect(rowTitles()).toEqual(['Alpha']); }); @@ -1479,41 +2029,32 @@ describe('SessionOverviewPanel', () => { '[data-slot="table"]', ) as HTMLTableElement; expect(renderedTable.dataset.layout).toBe('scroll'); - expect(renderedTable.style.minWidth).toBe('912px'); + expect(renderedTable.style.minWidth).toBe('696px'); expect(renderedTable.style.tableLayout).toBe('fixed'); expect(renderedTable.querySelectorAll('col')).toHaveLength(headers.length); expect( (renderedTable.querySelectorAll('col')[1] as HTMLTableColElement).style .width, - ).toBe('224px'); + ).toBe('260px'); 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((headers.at(-1) as HTMLElement).style.width).toBe('156px'); + expect((cells.at(-1) as HTMLElement).style.width).toBe('156px'); 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', + expect(headers.some((header) => header.textContent === 'Session ID')).toBe( + false, ); - const workspaceHeader = headers.find((header) => - header.textContent?.includes('Workspace'), + expect(headers.some((header) => header.textContent === 'Worktree')).toBe( + false, ); - const sessionIdColumnIndex = headers.indexOf(sessionIdHeader!); - expect((gitHeader as HTMLElement).style.width).toBe('144px'); + expect((headers[2] 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'); + expect((timeHeader as HTMLElement).style.width).toBe('96px'); const timeSortButton = timeHeader?.querySelector('button'); expect(timeSortButton?.className).toContain('px-0'); expect(timeSortButton?.className).toContain('text-sm'); @@ -1526,7 +2067,7 @@ describe('SessionOverviewPanel', () => { 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[1] as HTMLElement).style.width).toBe('260px'); expect(headers.at(-1)?.className).toContain('sticky'); expect((headers.at(-1) as HTMLElement).style.right).toBe('0px'); expect(cells[0]?.className).toContain('sticky'); @@ -1534,14 +2075,10 @@ describe('SessionOverviewPanel', () => { 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((cells[1] as HTMLElement).style.width).toBe('260px'); expect(titleTrigger(rows()[0]!).closest('.truncate')).not.toBeNull(); - expect(titleTrigger(rows()[0]!).className).toContain('text-xs'); + expect(titleTrigger(rows()[0]!).className).toContain('text-sm'); 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'); @@ -1595,9 +2132,9 @@ describe('SessionOverviewPanel', () => { 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'); + ).toBeGreaterThan(260); + expect((headers[1] as HTMLElement).style.minWidth).toBe('260px'); + expect((headers.at(-1) as HTMLElement).style.width).toBe('156px'); const columnWidth = Array.from(table.querySelectorAll('col')).reduce( (total, column) => total + parseFloat(column.style.width), 0, diff --git a/packages/web-shell/client/components/SessionOverviewPanel.tsx b/packages/web-shell/client/components/SessionOverviewPanel.tsx index 5dd21aa73dd..633d3f2520b 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.tsx @@ -4,7 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; import { useActions, useConnection, @@ -21,8 +28,10 @@ import type { import { ArchiveIcon, ArrowUpDownIcon, - CheckIcon, - CopyIcon, + CircleIcon, + CircleHelpIcon, + InfoIcon, + ShieldQuestionIcon, DownloadIcon, FunnelIcon, PenLineIcon, @@ -43,12 +52,7 @@ import { import { useI18n } from '../i18n'; import { SessionPrBadge } from './SessionPrBadge'; import { formatRelativeTime } from '../utils/formatRelativeTime'; -import { - 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'; @@ -74,12 +78,7 @@ import { DataTablePagination, type DataTableColumnMeta, } from './ui/data-table'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from './ui/tooltip'; +import { TooltipProvider } from './ui/tooltip'; import { AlertDialog, AlertDialogAction, @@ -103,68 +102,6 @@ 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 { @@ -205,6 +142,27 @@ export interface SessionCard { workspaceCwd: string; } +type SessionStatusFilter = 'all' | 'attention' | 'running' | 'idle'; + +const STATUS_FILTERS: SessionStatusFilter[] = [ + 'all', + 'attention', + 'running', + 'idle', +]; + +function matchesStatus( + card: SessionCard, + filter: SessionStatusFilter, +): boolean { + return ( + filter === 'all' || + (filter === 'attention' + ? card.status === 'needsApproval' || card.status === 'askUserQuestion' + : card.status === filter) + ); +} + type SessionIdentity = Pick; function getSessionIdentity(session: SessionIdentity): string { @@ -263,7 +221,8 @@ export function deriveSessionCards( ? 'needsApproval' : askUserQuestion ? 'askUserQuestion' - : (session.hasActivePrompt ?? status?.hasActivePrompt) + : (session.hasActivePrompt ?? status?.hasActivePrompt) || + session.activeWorkState === 'active' ? 'running' : 'idle', updatedAt: session.updatedAt || session.createdAt, @@ -484,10 +443,21 @@ function SessionOverviewPanelInner({ const [popupBlocked, setPopupBlocked] = useState(false); const [refreshing, setRefreshing] = useState(false); const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); const [excludedWorkspaceCwds, setExcludedWorkspaceCwds] = useState< Set >(() => new Set()); const [workspaceFilterOpen, setWorkspaceFilterOpen] = useState(false); + const [detailsOpen, setDetailsOpen] = useState<{ + identity: string; + click: boolean; + } | null>(null); + // One replacement owner for every details entry point of this panel, so + // the focus rescue only fires within the overview. + const sessionDetailsOwner = useId(); + // Stable column renderers keep the hover anchor mounted as details change. + const detailsOpenRef = useRef(detailsOpen); + detailsOpenRef.current = detailsOpen; const [busyIds, setBusyIds] = useState>(() => new Set()); const [actionError, setActionError] = useState(null); const [archiveTarget, setArchiveTarget] = useState( @@ -500,6 +470,7 @@ function SessionOverviewPanelInner({ // Inline rename state — mirrors the sidebar's double-click/rename flow. const [editingCard, setEditingCard] = useState(null); const [editingName, setEditingName] = useState(''); + const focusSortAfterRenameRef = useRef(false); const editingIdentity = editingCard ? getSessionIdentity(editingCard) : undefined; @@ -548,7 +519,7 @@ function SessionOverviewPanelInner({ }); }, [excludedWorkspaceCwds, workspaceOptions]); - const filteredCards = useMemo(() => { + const searchedCards = useMemo(() => { let list = cards; if (excludedWorkspaceCwds.size > 0) { list = list.filter( @@ -560,11 +531,49 @@ function SessionOverviewPanelInner({ list = list.filter( (card) => card.label.toLowerCase().includes(query) || - card.sessionId.toLowerCase().includes(query), + card.sessionId.toLowerCase().includes(query) || + card.gitBranch?.toLowerCase().includes(query) || + card.prs?.some((pr) => `#${pr.number}`.includes(query)), ); } return list; }, [cards, excludedWorkspaceCwds, searchQuery]); + const filteredCards = useMemo( + () => searchedCards.filter((card) => matchesStatus(card, statusFilter)), + [searchedCards, statusFilter], + ); + const sessionDetailsProps = useCallback( + (card: SessionCard, click = false) => { + const identity = getSessionIdentity(card); + return { + openOnClick: click, + ownerToken: sessionDetailsOwner, + open: + detailsOpenRef.current?.identity === identity && + detailsOpenRef.current.click === click, + onOpenChange: (open: boolean) => + setDetailsOpen((current) => + open + ? { identity, click } + : current?.identity === identity && current.click === click + ? null + : current, + ), + session: { + ...sessionByIdentity.get(getSessionIdentity(card)), + sessionId: card.sessionId, + workspaceCwd: card.workspaceCwd, + hasActivePrompt: card.status === 'running', + isWaitingForPermission: card.status === 'needsApproval', + isWaitingForUserQuestion: card.status === 'askUserQuestion', + }, + label: card.label, + time: card.updatedAt ? formatRelativeTime(card.updatedAt, t) : '', + completedUnread: false, + }; + }, + [sessionByIdentity, sessionDetailsOwner, t], + ); const isPrimaryCard = useCallback( (card: SessionCard) => { @@ -770,6 +779,7 @@ function SessionOverviewPanelInner({ const startRename = useCallback((card: SessionCard) => { setActionError(null); + setDetailsOpen(null); setEditingCard(card); setEditingName(card.label); }, []); @@ -1018,7 +1028,8 @@ function SessionOverviewPanelInner({ prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }, ); setRowSelection({}); - }, [excludedWorkspaceCwds, searchQuery]); + setDetailsOpen(null); + }, [excludedWorkspaceCwds, searchQuery, statusFilter]); useEffect(() => { const validIds = new Set(filteredCards.map(getSessionIdentity)); setRowSelection((prev) => { @@ -1078,267 +1089,192 @@ function SessionOverviewPanelInner({ cell: ({ row }) => { const card = row.original; return ( -
- {card.color && ( -
{popupBlocked && (
@@ -1612,7 +1714,11 @@ function SessionOverviewPanelInner({ } className={styles.tableViewport} rowClassName="cursor-pointer" - onRowClick={(row) => row.toggleSelected()} + onRowClick={(row) => { + if (editingCard || window.getSelection()?.isCollapsed === false) + return; + onOpenSession(row.original.sessionId, row.original.workspaceCwd); + }} data-web-shell-session-table-viewport /> @@ -1627,71 +1733,78 @@ function SessionOverviewPanelInner({ data-web-shell-session-footer > - {t('sessionsOverview.selectedRows', { - count: selectedCount, - total: filteredCards.length, - })} - -
- - - - {onOpenSplit && ( + total: filteredCards.length, + }, + )} + + {selectedCount > 0 && ( +
+ + - )} -
+ {onOpenSplit && ( + + )} +
+ )} vi.fn()); @@ -56,6 +58,183 @@ afterEach(() => { }); describe('SessionDetailsTooltip', () => { + it.each([ + [ + { + hasActivePrompt: true, + isWaitingForPermission: true, + isWaitingForUserQuestion: true, + }, + 'Needs approval', + ], + [ + { hasActivePrompt: true, isWaitingForUserQuestion: true }, + 'User input needed', + ], + [{ hasActivePrompt: true, activeWorkState: 'active' as const }, 'Running'], + [{ activeWorkState: 'active' as const }, 'Active work'], + [{ activeWorkState: 'unknown' as const }, 'Background activity unknown'], + ])( + 'prioritizes blocked states over an active prompt: %s', + async (flags, expected) => { + vi.useFakeTimers(); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + try { + act(() => + root.render( + + + + + , + ), + ); + await openDetails(container); + const details = document.querySelector('[role="dialog"]'); + expect(details?.textContent).toContain(expected); + if (expected !== 'Running') + expect(details?.textContent).not.toContain('Running'); + } finally { + act(() => root.unmount()); + } + }, + ); + + it('supports a ref-forwarding click trigger, keyboard dismissal and the host portal', async () => { + const container = document.createElement('div'); + container.setAttribute('data-web-shell-root', ''); + const portal = document.createElement('div'); + document.body.append(container, portal); + const root = createRoot(container); + const ref = createRef(); + const onRowClick = vi.fn(); + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + try { + await act(async () => + root.render( + + +
+ + + +
+
+
, + ), + ); + expect(ref.current).toBe(container.querySelector('button')); + expect(ref.current?.getAttribute('aria-expanded')).toBe('false'); + await act(async () => ref.current!.click()); + onRowClick.mockClear(); + const details = portal.querySelector('[role="dialog"]')!; + expect(details).not.toBeNull(); + expect(ref.current?.getAttribute('aria-expanded')).toBe('true'); + const copy = details.querySelector( + '[data-web-shell-session-id-copy]', + )!; + expect(copy.tabIndex).toBe(0); + expect(document.activeElement).toBe(copy); + await act(async () => { + details.dispatchEvent(new Event('pointerout', { bubbles: true })); + copy.click(); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + expect(writeText).toHaveBeenCalledExactlyOnceWith('click-details'); + expect(onRowClick).not.toHaveBeenCalled(); + expect(portal.querySelector('[role="dialog"]')).not.toBeNull(); + await act(async () => { + copy.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(portal.querySelector('[role="dialog"]')).toBeNull(); + expect(document.activeElement).toBe(ref.current); + } finally { + act(() => root.unmount()); + } + }); + + it("leaves another owner's pinned details open and focused on hover", async () => { + vi.useFakeTimers(); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + try { + await act(async () => + root.render( + + + + + + + + , + ), + ); + const [detailsButton, hoverTitle] = container.querySelectorAll('button'); + await act(async () => detailsButton!.click()); + const alpha = document.querySelector( + '[role="dialog"][aria-label="Alpha"]', + ); + const copy = alpha?.querySelector('[data-web-shell-session-id-copy]'); + expect(document.activeElement).toBe(copy); + await act(async () => { + hoverTitle!.dispatchEvent(new Event('pointerover', { bubbles: true })); + vi.advanceTimersByTime(300); + }); + await act(async () => vi.advanceTimersByTime(0)); + // A hover from a different owner must not steal focus from — or + // dismiss — this pinned popover. + expect( + document.querySelector('[role="dialog"][aria-label="Bravo"]'), + ).not.toBeNull(); + expect(alpha?.getAttribute('data-state')).toBe('open'); + expect(document.activeElement).toBe(copy); + } finally { + act(() => root.unmount()); + } + }); + it('shows the same structured details on row hover', async () => { vi.useFakeTimers(); const container = document.createElement('div'); @@ -99,6 +278,39 @@ describe('SessionDetailsTooltip', () => { act(() => root.unmount()); }); + it('preserves the standalone workspace label from the sidebar', async () => { + vi.useFakeTimers(); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + try { + act(() => + root.render( + + + + + , + ), + ); + await openDetails(container); + const details = document.querySelector('[role="dialog"]'); + expect(details?.textContent).toContain('No workspace'); + expect(details?.textContent).not.toContain('/internal/fallback'); + } finally { + act(() => root.unmount()); + } + }); + it('shows the bound pull request as a link', async () => { vi.useFakeTimers(); const container = document.createElement('div'); diff --git a/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx b/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx index 146ef4e0fce..6270c3a4c25 100644 --- a/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx +++ b/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx @@ -1,4 +1,11 @@ -import { useEffect, useRef, useState, type ReactElement } from 'react'; +import { + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type ReactElement, +} from 'react'; import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon'; import { CheckIcon, @@ -11,8 +18,12 @@ import { useI18n } from '../../i18n'; import { useExternalLinkOpener } from '../../hooks/useExternalLinkOpener'; import { writeClipboardText } from '../../utils/clipboard'; import { isExternalOpenUrl } from '../../utils/externalOpen'; -import { workspaceBasename } from '../../utils/workspace'; -import { Popover, PopoverAnchor, PopoverContent } from '../ui/popover'; +import { + Popover, + PopoverAnchor, + PopoverContent, + PopoverTrigger, +} from '../ui/popover'; import { SessionIssueStateIcon, SessionPrStateIcon, @@ -28,7 +39,10 @@ interface SessionDetailsTooltipProps { time: string; completedUnread: boolean; workspaceLabel?: string; - worktreeOnly?: boolean; + openOnClick?: boolean; + ownerToken?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; side?: 'right' | 'bottom'; children: ReactElement; } @@ -39,13 +53,18 @@ export function SessionDetailsTooltip({ time, completedUnread, workspaceLabel, - worktreeOnly = false, + openOnClick = false, + ownerToken, + open: controlledOpen, + onOpenChange, side = 'right', children, }: SessionDetailsTooltipProps) { const { t } = useI18n(); const openExternalLink = useExternalLinkOpener(); - const [open, setOpen] = useState(false); + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const open = controlledOpen ?? uncontrolledOpen; + const setOpen = onOpenChange ?? setUncontrolledOpen; const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'failed'>( 'idle', ); @@ -53,19 +72,24 @@ export function SessionDetailsTooltip({ const copyResetTimerRef = useRef(undefined); const openTimerRef = useRef(undefined); const closeTimerRef = useRef(undefined); - const anchorRef = useRef(null); + const anchorRef = useRef(null); + const contentRef = useRef(null); + const copyButtonRef = useRef(null); + const restoreFocusRef = useRef(false); + // The overview shares one token so its own entry points replace each + // other; every other instance keeps its own, so a hover elsewhere (e.g. + // the sidebar) never moves focus out of this popover. + const selfOwnerToken = useId(); + const detailsOwner = ownerToken ?? selfOwnerToken; const collisionBoundary = open ? side === 'bottom' ? [ anchorRef.current?.closest('[data-pane-session-id]'), anchorRef.current?.closest('[data-web-shell-root]'), ].filter((element): element is HTMLElement => Boolean(element)) - : resolveSessionDetailsCollisionBoundary( - anchorRef.current?.closest('aside') ?? null, - ) + : resolveSessionDetailsCollisionBoundary(anchorRef.current) : null; const folderPath = session.workspaceCwd; - const folderName = workspaceLabel ?? workspaceBasename(folderPath); const branch = session.worktree?.branch ?? session.branch?.name; const prs = [...(session.prs ?? [])] .reverse() @@ -80,15 +104,19 @@ export function SessionDetailsTooltip({ !seenIssueUrls.has(issue.url) && seenIssueUrls.add(issue.url), ); - const status = session.hasActivePrompt - ? t('sidebar.running') - : session.activeWorkState === 'active' - ? t('sidebar.activeWork') - : session.activeWorkState === 'unknown' - ? t('sidebar.activityUnknown') - : completedUnread - ? t('sidebar.completedUnread') - : t('sidebar.clients', { count: session.clientCount ?? 0 }); + const status = session.isWaitingForPermission + ? t('sessionsOverview.status.needsApproval') + : session.isWaitingForUserQuestion + ? t('sessionsOverview.status.askUserQuestion') + : session.hasActivePrompt + ? t('sidebar.running') + : session.activeWorkState === 'active' + ? t('sidebar.activeWork') + : session.activeWorkState === 'unknown' + ? t('sidebar.activityUnknown') + : completedUnread + ? t('sidebar.completedUnread') + : `${t('sessionsOverview.status.idle')} · ${t('sidebar.clients', { count: session.clientCount ?? 0 })}`; useEffect(() => { return () => { @@ -99,22 +127,62 @@ export function SessionDetailsTooltip({ }; }, []); + // A click-pinned popover unmounts together with its anchor row (e.g. live + // state re-sorts the row off the page). Layout cleanups run before the + // row's DOM is removed, so if the popover still holds focus, hand it to + // the panel root here — otherwise the browser drops it to . + useLayoutEffect(() => { + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps -- read at unmount, a mount-time copy would always be null + const content = contentRef.current; + if (!content) return; + let active: Element | null = content.ownerDocument.activeElement; + while (active?.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + if (!active || !content.contains(active)) return; + anchorRef.current + ?.closest('[data-web-shell-session-panel]') + ?.focus({ preventScroll: true }); + }; + }, []); + useEffect(() => { copyAttemptRef.current += 1; window.clearTimeout(copyResetTimerRef.current); setCopyStatus('idle'); - }, [session.sessionId]); + if (!open) { + window.clearTimeout(openTimerRef.current); + window.clearTimeout(closeTimerRef.current); + } + }, [session.sessionId, open]); const cancelClose = () => window.clearTimeout(closeTimerRef.current); const openAfterDelay = () => { cancelClose(); if (open) return; window.clearTimeout(openTimerRef.current); - openTimerRef.current = window.setTimeout(() => setOpen(true), 300); + openTimerRef.current = window.setTimeout(() => { + const anchor = anchorRef.current; + let activeElement = anchor?.ownerDocument.activeElement; + while (activeElement?.shadowRoot?.activeElement) { + activeElement = activeElement.shadowRoot.activeElement; + } + // Move focus before replacing its owner so the new hover stays open. + if ( + activeElement?.closest( + `[data-web-shell-session-details-content="${detailsOwner}"]`, + ) + ) { + anchor?.focus({ preventScroll: true }); + } + setOpen(true); + }, 300); }; const close = () => { window.clearTimeout(openTimerRef.current); cancelClose(); + restoreFocusRef.current = true; setOpen(false); copyAttemptRef.current += 1; window.clearTimeout(copyResetTimerRef.current); @@ -126,177 +194,215 @@ export function SessionDetailsTooltip({ closeTimerRef.current = window.setTimeout(close, 100); }; const handleOpenChange = (nextOpen: boolean) => { - if (nextOpen) setOpen(true); - else close(); + if (nextOpen) { + restoreFocusRef.current = false; + setOpen(true); + } else close(); }; return ( - { - if (event.currentTarget.contains(event.target as Node)) { - openAfterDelay(); - } - }} - onPointerLeave={closeAfterDelay} - onPointerDownCapture={close} - onClick={() => handleOpenChange(false)} - > - {children} - + {openOnClick ? ( + { + anchorRef.current = node; + }} + asChild + > + {children} + + ) : ( + { + anchorRef.current = node; + }} + asChild + onPointerEnter={(event) => { + if (event.currentTarget.contains(event.target as Node)) { + openAfterDelay(); + } + }} + onPointerLeave={closeAfterDelay} + onPointerDownCapture={close} + onClick={() => handleOpenChange(false)} + > + {children} + + )} event.preventDefault()} - onPointerEnter={cancelClose} - onPointerLeave={closeAfterDelay} - className={styles.sessionDetailsTooltip} + onOpenAutoFocus={(event) => { + if (!openOnClick) { + event.preventDefault(); + return; + } + requestAnimationFrame(() => { + copyButtonRef.current?.scrollIntoView({ block: 'nearest' }); + }); + }} + onPointerEnter={openOnClick ? undefined : cancelClose} + onCloseAutoFocus={(event) => { + if (!restoreFocusRef.current) event.preventDefault(); + }} + onPointerLeave={openOnClick ? undefined : closeAfterDelay} + onClick={(event) => event.stopPropagation()} + className={`${styles.sessionDetailsTooltip} max-h-(--radix-popover-content-available-height)`} > - {!worktreeOnly && ( - <> -
- - {label} - - {time && ( - {time} - )} -
-
-
- - )} - {branch && ( -
-
- )} - {prs.map((pr, index) => { - const stateLabel = sessionPrStateLabel(t, pr.state); - return ( - // Index composite: a hand-edited sidecar can carry duplicate - // numbers (the reader validates shape, not uniqueness), and a - // duplicate key would reconcile rows against each other. The - // list is a stable per-snapshot order, so index keys are safe. - + {branch && (
-
-
- {session.sessionId} - - + { + event.stopPropagation(); + openExternalLink(event, pr.url); + }} + > + {t('sidebar.sessionPr', { number: pr.number })} + {stateLabel ? ( + {` · ${stateLabel}`} + ) : null} + +
+ ); + })} + {issues.map((issue, index) => { + const stateLabel = sessionIssueStateLabel(t, issue.state); + return ( +
- {copyStatus === 'copied' - ? t('sidebar.sessionIdCopied') - : copyStatus === 'failed' - ? t('sidebar.copySessionIdFailed') - : ''} - -
- - )} + + { + event.stopPropagation(); + openExternalLink(event, issue.url); + }} + > + {t('sidebar.sessionIssue', { number: issue.number })} + {stateLabel ? ( + {` · ${stateLabel}`} + ) : null} + +
+ ); + })} +
+
+
+ + {session.sessionId} + + + + {copyStatus === 'copied' + ? t('sidebar.sessionIdCopied') + : copyStatus === 'failed' + ? t('sidebar.copySessionIdFailed') + : ''} + +
+ ); diff --git a/packages/web-shell/client/components/sidebar/sessionDetailsCollisionBoundary.test.ts b/packages/web-shell/client/components/sidebar/sessionDetailsCollisionBoundary.test.ts index 2376fc69e06..100431371fd 100644 --- a/packages/web-shell/client/components/sidebar/sessionDetailsCollisionBoundary.test.ts +++ b/packages/web-shell/client/components/sidebar/sessionDetailsCollisionBoundary.test.ts @@ -14,6 +14,20 @@ describe('resolveSessionDetailsCollisionBoundary', () => { expect(resolveSessionDetailsCollisionBoundary(sidebar)).toBe(webShellRoot); }); + it('resolves an overview anchor inside a shell without an aside', () => { + const shell = document.createElement('div'); + shell.dataset.webShellRoot = ''; + const anchor = document.createElement('button'); + shell.append(anchor); + expect(resolveSessionDetailsCollisionBoundary(anchor)).toBe(shell); + }); + + it('does not constrain standalone details to the trigger rectangle', () => { + expect( + resolveSessionDetailsCollisionBoundary(document.createElement('button')), + ).toBeNull(); + }); + it('falls back to the sidebar when no WebShell root is present', () => { const sidebar = document.createElement('aside'); diff --git a/packages/web-shell/client/components/sidebar/sessionDetailsCollisionBoundary.ts b/packages/web-shell/client/components/sidebar/sessionDetailsCollisionBoundary.ts index 711b4ec4ca4..c3c597797cf 100644 --- a/packages/web-shell/client/components/sidebar/sessionDetailsCollisionBoundary.ts +++ b/packages/web-shell/client/components/sidebar/sessionDetailsCollisionBoundary.ts @@ -1,5 +1,9 @@ export function resolveSessionDetailsCollisionBoundary( - sidebar: HTMLElement | null, + anchor: HTMLElement | null, ): HTMLElement | null { - return sidebar?.closest('[data-web-shell-root]') ?? sidebar; + return ( + anchor?.closest('[data-web-shell-root]') ?? + anchor?.closest('aside') ?? + null + ); } diff --git a/packages/web-shell/client/e2e/session-overview-shadow-dom.html b/packages/web-shell/client/e2e/session-overview-shadow-dom.html new file mode 100644 index 00000000000..b1c6ae30fbc --- /dev/null +++ b/packages/web-shell/client/e2e/session-overview-shadow-dom.html @@ -0,0 +1,27 @@ + + + + + + Session overview shadow portal harness + + +
+ + + diff --git a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts index 47a60fb56cf..78f5e741c48 100644 --- a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts +++ b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts @@ -53,6 +53,63 @@ function createTerminalTurnErrorScenario(sessionId: string) { for (const theme of THEMES) { test.describe(`web-shell screenshots (${theme})`, () => { + test('session overview', async ({ page }, testInfo) => { + const workspaceCwd = '/workspace/session-overview'; + const scenario = createWebShellDaemonScenario({ + workspaceCwd, + sessions: [ + { + displayName: 'Review release approval', + isWaitingForPermission: true, + }, + { + displayName: 'Choose the export format', + isWaitingForUserQuestion: true, + }, + { displayName: 'Run the browser tests', hasActivePrompt: true }, + { displayName: 'Update session documentation' }, + ].map((session, index) => ({ + ...session, + sessionId: `overview-${index}`, + workspaceCwd, + updatedAt: '2026-07-01T12:00:00.000Z', + branch: { + name: + index === 0 + ? 'feature/session-overview-with-complete-metadata-in-constrained-viewports' + : 'feature/session-overview', + baseBranch: 'main', + }, + })), + }); + const daemon = await installScenario( + page, + scenario, + resolveBaseURL(testInfo), + ); + await gotoSession(page, scenario, daemon, theme); + await page + .getByRole('button', { name: 'Session Overview', exact: true }) + .click(); + await expect( + page.locator('[data-web-shell-session-panel]'), + ).toContainText('Review release approval'); + await captureScreenshot(page, `session-overview-${theme}`); + await page + .getByRole('button', { + name: 'Details for Review release approval', + exact: true, + }) + .click(); + await expect( + page.getByRole('dialog', { + name: 'Review release approval', + exact: true, + }), + ).toBeVisible(); + await captureScreenshot(page, `session-overview-details-${theme}`); + }); + test(`session transcript`, async ({ page }, testInfo) => { const scenario = createWebShellDaemonScenario({ events: [ diff --git a/packages/web-shell/client/e2e/web-shell.collapsed-groups-persist.spec.ts b/packages/web-shell/client/e2e/web-shell.collapsed-groups-persist.spec.ts index 425b8ce4a9c..f3286d176af 100644 --- a/packages/web-shell/client/e2e/web-shell.collapsed-groups-persist.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.collapsed-groups-persist.spec.ts @@ -14,6 +14,9 @@ import { type WebShellDaemonScenario, } from './utils/mockDaemon'; +const longBranch = + 'feature/session-details-with-a-very-long-branch-name-for-constrained-viewports'; + test('persists collapsed session groups across reload @smoke', async ({ page, }, testInfo) => { @@ -81,12 +84,12 @@ test('keeps long session details inside a constrained WebShell @smoke', async ({ await sessionTitle.hover(); const details = page.getByRole('dialog', { name: longTitle }); - const title = details.getByTitle(longTitle); + const title = details.getByText(longTitle, { exact: true }); const copyAction = details.getByRole('button', { name: 'Copy session ID', }); await expect(details).toBeVisible(); - await expect(title).toHaveAttribute('title', longTitle); + await expect(title).toHaveText(longTitle); await expect(copyAction).toBeVisible(); await expect( details.getByText(scenario.sessionId, { exact: true }), @@ -106,22 +109,46 @@ test('keeps long session details inside a constrained WebShell @smoke', async ({ await sessionTitle.hover(); await expect(details).toBeVisible(); await expectDetailsInsideRoot(webShellRoot, details); + await copyAction.scrollIntoViewIfNeeded(); + await expect(copyAction).toBeInViewport(); await expect(copyAction).toBeVisible(); } - const titleMetrics = await title.evaluate((element) => { - const style = window.getComputedStyle(element); - return { - clientHeight: element.clientHeight, - lineHeight: Number.parseFloat(style.lineHeight), - clientWidth: element.clientWidth, - scrollWidth: element.scrollWidth, - }; - }); - expect(titleMetrics.clientHeight).toBeLessThanOrEqual( - titleMetrics.lineHeight + 1, - ); - expect(titleMetrics.scrollWidth).toBeGreaterThan(titleMetrics.clientWidth); + for (const [name, value] of Object.entries({ + title, + workspace: details.getByTitle(scenario.workspaceCwd, { exact: true }), + sessionId: details.locator('[data-web-shell-session-id]'), + branch: details.getByTitle(longBranch, { exact: true }), + })) { + await test.step(`${name} wraps without clipping`, async () => { + await expect(value).toHaveCount(1); + const metrics = await value.evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + lineHeight: Number.parseFloat(getComputedStyle(element).lineHeight), + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })); + expect(metrics.clientHeight, `${name} wraps`).toBeGreaterThan( + metrics.lineHeight, + ); + expect(metrics.scrollHeight, `${name} height`).toBeLessThanOrEqual( + metrics.clientHeight + 1, + ); + expect(metrics.scrollWidth, `${name} width`).toBeLessThanOrEqual( + metrics.clientWidth + 1, + ); + }); + } + expect( + await copyAction.evaluate((button) => { + const scroller = button.closest('[role="dialog"]')!.firstElementChild!; + return getComputedStyle(scroller).overscrollBehaviorY; + }), + ).toBe('contain'); + await copyAction.click(); + await expect(details).toContainText('Session ID copied'); + await expectDetailsInsideRoot(webShellRoot, details); }); async function expectDetailsInsideRoot( @@ -151,8 +178,10 @@ async function expectDetailsInsideRoot( function createOrganizedScenario( currentSessionDisplayName = 'E2E Harness Session', ): WebShellDaemonScenario { - const workspaceCwd = '/tmp/qwen-web-shell-e2e'; - const sessionId = 'web-shell-e2e-session'; + const workspaceCwd = + '/tmp/qwen-web-shell-e2e/workspaces/feature-session-details/packages/web-shell/client/components/sidebar'; + const sessionId = + 'web-shell-e2e-session-with-a-long-id-that-wraps-across-multiple-lines'; return createWebShellDaemonScenario({ workspaceCwd, sessionId, @@ -184,6 +213,10 @@ function createOrganizedScenario( createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:00.000Z', displayName: currentSessionDisplayName, + branch: { + name: longBranch, + baseBranch: 'main', + }, clientCount: 1, hasActivePrompt: false, groupId: null, diff --git a/packages/web-shell/client/e2e/web-shell.session-overview.spec.ts b/packages/web-shell/client/e2e/web-shell.session-overview.spec.ts new file mode 100644 index 00000000000..aa791a2c76f --- /dev/null +++ b/packages/web-shell/client/e2e/web-shell.session-overview.spec.ts @@ -0,0 +1,547 @@ +import { expect, test } from '@playwright/test'; +import { + createWebShellDaemonScenario, + installMockDaemon, +} from './utils/mockDaemon'; + +const workspaceCwd = '/tmp/session-overview-e2e/project'; +const sessions = [ + { + sessionId: 'approval-session', + displayName: 'Approve fixture', + isWaitingForPermission: true, + hasActivePrompt: true, + }, + { + sessionId: 'question-session', + displayName: 'Question fixture', + isWaitingForUserQuestion: true, + hasActivePrompt: true, + }, + { + sessionId: 'running-session', + displayName: 'Running fixture', + hasActivePrompt: true, + }, + { + sessionId: 'idle-session', + displayName: 'Idle fixture', + hasActivePrompt: false, + }, +].map((session, index) => ({ + ...session, + workspaceCwd, + clientCount: 1, + updatedAt: '2026-09-07T00:00:00.000Z', + branch: { + name: + session.sessionId === 'idle-session' + ? 'feature/idle-search' + : 'feature/overview-preview', + baseBranch: 'main', + }, + prs: Array.from( + { length: session.sessionId === 'question-session' ? 8 : 1 }, + (_, prIndex) => ({ + number: 4567 + index + prIndex * 10, + url: `https://github.com/example/repo/pull/${4567 + index + prIndex * 10}`, + state: 'open' as const, + issues: [ + { + number: 1234 + prIndex, + url: `https://github.com/example/repo/issues/${1234 + prIndex}`, + state: 'open' as const, + }, + ], + }), + ), +})); + +test.beforeEach(async ({ page }, testInfo) => { + const scenario = createWebShellDaemonScenario({ + workspaceCwd, + sessionId: 'idle-session', + displayName: 'Idle fixture', + sessions, + capabilities: { + features: [ + 'session_events', + 'session_source_metadata', + 'workspace_session_live_state', + 'session_archive', + 'workspace_session_metadata', + ], + }, + }); + await installMockDaemon(page, scenario, { + baseURL: String(testInfo.project.use.baseURL), + }); + await page.goto('/'); + await page + .getByRole('button', { name: 'Session Overview', exact: true }) + .click(); + await expect( + page + .locator('[data-web-shell-session-panel]') + .getByRole('button', { name: 'Approve fixture', exact: true }), + ).toBeVisible(); +}); + +test('compact overview distinguishes states and filters checkbox selection @smoke', async ({ + page, +}) => { + const panel = page.locator('[data-web-shell-session-panel]'); + await expect( + panel.getByRole('columnheader', { name: 'Session ID' }), + ).toHaveCount(0); + for (const [status, label] of [ + ['needsApproval', 'Needs approval'], + ['askUserQuestion', 'User input needed'], + ['running', 'Running'], + ['idle', 'Idle'], + ] as const) { + await expect( + panel.locator(`[data-web-shell-session-status="${status}"]`), + ).toHaveText(label); + await expect( + panel.locator(`[data-web-shell-session-status="${status}"]`), + ).toHaveAttribute('title', label); + } + for (const name of [ + 'Approve fixture', + 'Question fixture', + 'Running fixture', + ]) { + const row = panel.getByRole('row').filter({ hasText: name }); + await expect( + row.getByRole('button', { name: 'Archive', exact: true }), + ).toBeDisabled(); + await expect( + row.getByRole('button', { name: 'Delete', exact: true }), + ).toBeDisabled(); + } + await expect( + panel + .locator('[data-web-shell-session-footer]') + .getByRole('button', { name: 'Open in new tab', exact: true }), + ).toHaveCount(0); + await panel + .getByRole('checkbox', { name: 'Select Idle fixture', exact: true }) + .check(); + await expect( + panel + .locator('[data-web-shell-session-footer]') + .getByRole('button', { name: 'Open in new tab', exact: true }), + ).toBeVisible(); + await expect(panel).toBeVisible(); + const filters = panel.getByRole('group', { + name: 'Filter by session status', + }); + await filters.getByRole('button', { name: /Needs attention/ }).click(); + await expect(panel.locator('[data-web-shell-session-title]')).toHaveCount(2); + await expect(panel.getByRole('checkbox', { checked: true })).toHaveCount(0); + await filters.getByRole('button', { name: /All/ }).click(); + const search = panel.getByRole('textbox', { + name: 'Search title, branch, PR or ID…', + }); + for (const query of [ + 'feature/idle-search', + '#4570', + 'idle-session', + 'Idle fixture', + ]) { + await search.fill(query); + await expect(panel.locator('[data-web-shell-session-title]')).toHaveText([ + 'Idle fixture', + ]); + } + await search.fill(''); + await expect(panel.locator('[data-web-shell-session-title]')).toHaveCount(4); +}); + +test('title hover exposes full metadata, permits copy, and links keep overview open @smoke', async ({ + page, + context, +}) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await context.route('https://github.com/example/repo/**', (route) => + route.fulfill({ body: 'Synthetic external destination' }), + ); + const panel = page.locator('[data-web-shell-session-panel]'); + const title = panel.getByRole('button', { + name: 'Approve fixture', + exact: true, + }); + await title.hover(); + const dialog = page.getByRole('dialog', { + name: 'Approve fixture', + exact: true, + }); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText(workspaceCwd); + await expect(dialog).toContainText('feature/overview-preview'); + await expect(dialog).toContainText('Needs approval'); + await expect(dialog.locator('[data-web-shell-session-id]')).toHaveText( + 'approval-session', + ); + await dialog.locator('[data-web-shell-session-id-copy]').click(); + await expect(dialog).toContainText('Session ID copied'); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe('approval-session'); + await expect(panel).toBeVisible(); + await expect(panel.getByRole('checkbox', { checked: true })).toHaveCount(0); + const popupPromise = page.waitForEvent('popup'); + await dialog.getByRole('link', { name: /Pull Request #4567/ }).click(); + const popup = await popupPromise; + await expect(popup).toHaveURL('https://github.com/example/repo/pull/4567'); + await popup.close(); + await expect(panel).toBeVisible(); + await title.hover(); + await expect( + dialog.getByRole('link', { name: /Issue #1234/ }), + ).toHaveAttribute('href', 'https://github.com/example/repo/issues/1234'); +}); + +test('details button supports keyboard and Escape restores focus without navigating @smoke', async ({ + page, +}) => { + const panel = page.locator('[data-web-shell-session-panel]'); + await page.setViewportSize({ width: 900, height: 420 }); + const button = panel.getByRole('button', { + name: 'Details for Question fixture', + }); + await button.press('Enter'); + const dialog = page.getByRole('dialog', { + name: 'Question fixture', + exact: true, + }); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText('User input needed'); + await expect( + dialog.locator('[data-web-shell-session-id-copy]'), + ).toBeFocused(); + await expect( + dialog.locator('[data-web-shell-session-id-copy]'), + ).toBeInViewport(); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await expect(button).toBeFocused(); + await expect(panel).toBeVisible(); + await button.click(); + await expect(dialog).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); +}); + +test('row opens a session without selecting it @smoke', async ({ page }) => { + const panel = page.locator('[data-web-shell-session-panel]'); + const idle = panel.getByRole('row').filter({ hasText: 'Idle fixture' }); + await idle.locator('[data-web-shell-session-status]').click(); + await expect(panel).toHaveCount(0); + await expect(page).toHaveURL(/\/session\/idle-session/); +}); + +test('keeps text selection and rename drafts in the overview @smoke', async ({ + page, +}) => { + const panel = page.locator('[data-web-shell-session-panel]'); + const idle = panel.getByRole('row').filter({ + has: page.getByRole('checkbox', { + name: 'Select Idle fixture', + exact: true, + }), + }); + const workspace = await idle + .locator('[data-web-shell-session-workspace]') + .boundingBox(); + const branch = await idle + .locator('[data-web-shell-session-git]') + .boundingBox(); + expect(workspace).not.toBeNull(); + expect(branch).not.toBeNull(); + await page.mouse.move(workspace!.x + 2, workspace!.y + workspace!.height / 2); + await page.mouse.down(); + await page.mouse.move( + branch!.x + branch!.width / 2, + branch!.y + branch!.height / 2, + { steps: 10 }, + ); + await page.mouse.up(); + await expect(panel).toBeVisible(); + expect(await page.evaluate(() => window.getSelection()?.isCollapsed)).toBe( + false, + ); + await page.evaluate(() => window.getSelection()?.removeAllRanges()); + await idle.getByRole('button', { name: 'Rename', exact: true }).click(); + const editor = idle.getByRole('textbox', { name: 'Rename: Idle fixture' }); + await editor.fill('Unsaved rename'); + await idle + .locator('td') + .nth(2) + .click({ position: { x: 130, y: 20 } }); + await expect(panel).toBeVisible(); + await expect(editor).toBeFocused(); + await expect(editor).toHaveValue('Unsaved rename'); + // Controls in other rows still take their click: the blur-cancel re-render + // must not detach the pressed node before mouseup. + const approveCheckbox = panel.getByRole('checkbox', { + name: 'Select Approve fixture', + exact: true, + }); + await approveCheckbox.click(); + await expect(approveCheckbox).toBeChecked(); + await expect(editor).toHaveValue('Unsaved rename'); + await editor.press('Escape'); + await expect(editor).toHaveCount(0); +}); + +test('replaces clicked details when hovering another entry @smoke', async ({ + page, +}) => { + const panel = page.locator('[data-web-shell-session-panel]'); + await panel.getByRole('button', { name: 'Details for Idle fixture' }).click(); + await expect( + page.getByRole('dialog', { name: 'Idle fixture', exact: true }), + ).toBeVisible(); + await panel + .getByRole('button', { name: 'Approve fixture', exact: true }) + .hover(); + await expect( + page.getByRole('dialog', { name: 'Approve fixture', exact: true }), + ).toBeVisible(); + await expect(page.getByRole('dialog')).toHaveCount(1); + await expect( + page.getByRole('dialog', { name: 'Approve fixture', exact: true }), + ).toHaveAttribute('data-state', 'open'); + const title = panel.getByRole('button', { + name: 'Approve fixture', + exact: true, + }); + await expect(title).toBeFocused(); + await page.mouse.move(0, 0); + await expect(page.getByRole('dialog')).toHaveCount(0); + await expect(title).toBeFocused(); + await page.keyboard.press('Tab'); + await expect( + panel + .getByRole('row') + .filter({ + has: page.getByRole('button', { name: 'Approve fixture', exact: true }), + }) + .getByRole('link', { name: /#4567/ }), + ).toBeFocused(); +}); + +for (const count of [4, 11]) { + test(`sorting during rename preserves keyboard navigation with ${count} sessions @smoke`, async ({ + page, + }, testInfo) => { + const scenario = createWebShellDaemonScenario({ + workspaceCwd, + sessionId: 'sort-0', + sessions: Array.from({ length: count }, (_, index) => ({ + sessionId: `sort-${index}`, + workspaceCwd, + displayName: `Sort session ${index}`, + updatedAt: new Date(Date.UTC(2026, 8, 20 - index)).toISOString(), + })), + capabilities: { + features: [ + 'session_events', + 'session_source_metadata', + 'workspace_session_metadata', + ], + }, + }); + await installMockDaemon(page, scenario, { + baseURL: String(testInfo.project.use.baseURL), + }); + await page.evaluate(() => + localStorage.setItem('qwen-web-shell-session-overview-page-size', '10'), + ); + await page.goto('/'); + await page + .getByRole('button', { name: 'Session Overview', exact: true }) + .click(); + const panel = page.locator('[data-web-shell-session-panel]'); + await panel + .getByRole('row') + .filter({ hasText: 'Sort session 0' }) + .getByRole('button', { name: 'Rename', exact: true }) + .click(); + const editor = panel.getByRole('textbox', { + name: 'Rename: Sort session 0', + exact: true, + }); + await editor.fill('Unsaved name'); + const sort = panel.getByRole('button', { name: 'Time', exact: true }); + await sort.click(); + await expect( + panel.getByRole('columnheader', { name: 'Time', exact: true }), + ).toHaveAttribute('aria-sort', 'ascending'); + await expect(editor).toHaveCount(0); + await expect(sort).toBeFocused(); + await expect( + panel.getByRole('button', { name: 'Sort session 0', exact: true }), + ).toHaveCount(count === 4 ? 1 : 0); + await page.keyboard.press('Tab'); + await expect( + panel.locator('tbody').getByRole('checkbox').first(), + ).toBeFocused(); + }); +} + +test('hover details preserve a focused search field @smoke', async ({ + page, +}) => { + const panel = page.locator('[data-web-shell-session-panel]'); + const search = panel.getByRole('textbox', { + name: 'Search title, branch, PR or ID…', + }); + await search.focus(); + await panel + .getByRole('button', { name: 'Approve fixture', exact: true }) + .hover(); + await expect( + page.getByRole('dialog', { name: 'Approve fixture', exact: true }), + ).toBeVisible(); + await expect(search).toBeFocused(); + await search.fill('Running fixture'); + await expect(page.getByRole('dialog')).toHaveCount(0); + await expect(search).toBeFocused(); +}); + +test('shadow portal details keep focus in the overview after hover replacement @smoke', async ({ + page, +}) => { + await page.goto('/e2e/session-overview-shadow-dom.html'); + await page + .getByRole('button', { name: 'Session Overview', exact: true }) + .click(); + const panel = page.locator('[data-web-shell-session-panel]'); + await panel.getByRole('button', { name: 'Details for Idle fixture' }).click(); + const copy = page.getByRole('button', { name: 'Copy session ID' }); + await expect(copy).toBeFocused(); + expect( + await copy.evaluate( + (element) => element.getRootNode() instanceof ShadowRoot, + ), + ).toBe(true); + const title = panel.getByRole('button', { + name: 'Approve fixture', + exact: true, + }); + await title.hover(); + await expect(page.getByRole('dialog')).toHaveCount(1); + await expect( + page.getByRole('dialog', { name: 'Approve fixture', exact: true }), + ).toHaveAttribute('data-state', 'open'); + await expect(title).toBeFocused(); + await page.mouse.move(0, 0); + await expect(page.getByRole('dialog')).toHaveCount(0); + await expect(title).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(panel.getByRole('link', { name: /#4567/ })).toBeFocused(); + const search = panel.getByRole('textbox', { + name: 'Search title, branch, PR or ID…', + }); + await search.focus(); + await title.hover(); + await expect( + page.getByRole('dialog', { name: 'Approve fixture', exact: true }), + ).toHaveAttribute('data-state', 'open'); + await expect(search).toBeFocused(); +}); + +test('overview details stay inside an embedded shell @smoke', async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + const shell = page.locator('[data-web-shell-root]'); + await shell.evaluate((element) => + Object.assign((element as HTMLElement).style, { + position: 'fixed', + inset: '140px auto auto 180px', + width: '800px', + height: '420px', + minHeight: '0', + }), + ); + await page + .getByRole('button', { name: 'Details for Question fixture', exact: true }) + .click(); + const dialog = page.getByRole('dialog', { + name: 'Question fixture', + exact: true, + }); + await expect(dialog).toBeVisible(); + await expect + .poll(async () => { + const bounds = await shell.boundingBox(); + const details = await dialog.boundingBox(); + return ( + !!bounds && + !!details && + details.x >= bounds.x - 1 && + details.y >= bounds.y - 1 && + details.x + details.width <= bounds.x + bounds.width + 1 && + details.y + details.height <= bounds.y + bounds.height + 1 + ); + }) + .toBe(true); + expect( + await dialog.evaluate( + (element) => element.closest('[data-web-shell-portal-root]') !== null, + ), + ).toBe(true); +}); + +test('attention cues stay visible in the pinned title at narrow widths @smoke', async ({ + page, +}) => { + await page.setViewportSize({ width: 499, height: 720 }); + const panel = page.locator('[data-web-shell-session-panel]'); + const scroller = panel.locator('[data-slot="table-container"]'); + for (const position of ['start', 'end'] as const) { + await scroller.evaluate((element, position) => { + element.scrollLeft = position === 'start' ? 0 : element.scrollWidth; + }, position); + if (position === 'start') { + await expect + .poll(() => scroller.evaluate((element) => element.scrollLeft)) + .toBe(0); + } else { + await expect + .poll(() => scroller.evaluate((element) => element.scrollLeft)) + .toBeGreaterThan(0); + } + for (const [status, label] of [ + ['needsApproval', 'Needs approval'], + ['askUserQuestion', 'User input needed'], + ['running', 'Running'], + ] as const) { + const cue = panel.locator( + `[data-web-shell-session-status-cue="${status}"]`, + ); + await expect(cue).toBeVisible(); + await expect(cue).toHaveAttribute('title', label); + await expect + .poll(() => + cue.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return element.contains( + document.elementFromPoint( + rect.x + rect.width / 2, + rect.y + rect.height / 2, + ), + ); + }), + ) + .toBe(true); + } + } + await expect( + panel.locator('[data-web-shell-session-status-cue="idle"]'), + ).toHaveCount(0); +}); diff --git a/packages/web-shell/client/e2e/web-shell.split-persist.spec.ts b/packages/web-shell/client/e2e/web-shell.split-persist.spec.ts index b43815093a5..65a45107b8f 100644 --- a/packages/web-shell/client/e2e/web-shell.split-persist.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.split-persist.spec.ts @@ -168,9 +168,7 @@ test('shows session details without moving focus and preserves drafts across pan await expect(details).toBeVisible(); await expect(details).toHaveAttribute('data-side', 'bottom'); await expect(details.getByText(SESSION_B, { exact: true })).toBeVisible(); - await expect( - details.getByText('qwen-web-shell-e2e', { exact: true }), - ).toBeVisible(); + await expect(details.getByText(WORKSPACE_CWD, { exact: true })).toBeVisible(); await expect(editorA).toBeFocused(); await expect(paneB.getByTestId('chat-pane')).not.toHaveAttribute( 'data-pane-active', diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 93096ae6337..6d6b57439d9 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -3115,11 +3115,8 @@ const EN: Messages = { 'sessionsOverview.refresh': 'Refresh', 'sessionsOverview.selectAll': 'Select all', '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', @@ -3129,7 +3126,19 @@ const EN: Messages = { 'sessionsOverview.rowsPerPage': 'Rows per page', 'sessionsOverview.workspaceFilter': 'Filter by workspace', 'sessionsOverview.allWorkspaces': 'All', - 'sessionsOverview.searchPlaceholder': 'Search sessions…', + 'sessionsOverview.searchPlaceholder': 'Search title, branch, PR or ID…', + 'sessionsOverview.workspaceAll': 'All workspaces', + 'sessionsOverview.workspacesSelected': (v) => + `${v?.count ?? 0}/${v?.total ?? 0} workspaces`, + 'sessionsOverview.statusColumn': 'Status', + 'sessionsOverview.statusFilter': 'Filter by session status', + 'sessionsOverview.filter.all': 'All', + 'sessionsOverview.filter.attention': 'Needs attention', + 'sessionsOverview.filter.running': 'Running', + 'sessionsOverview.filter.idle': 'Idle', + 'sessionsOverview.status.idle': 'Idle', + 'sessionsOverview.details': (v) => `Details for ${v?.name ?? ''}`, + 'sessionsOverview.sessionCount': (v) => `${v?.total ?? 0} session(s)`, 'sessionsOverview.confirmArchiveTitle': 'Archive session?', 'sessionsOverview.confirmArchive': (v) => `"${v?.name ?? ''}" will be moved to archived sessions.`, @@ -6511,11 +6520,8 @@ const ZH: Messages = { 'sessionsOverview.refresh': '刷新', 'sessionsOverview.selectAll': '全选', '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': '上一页', @@ -6525,7 +6531,19 @@ const ZH: Messages = { 'sessionsOverview.rowsPerPage': '每页行数', 'sessionsOverview.workspaceFilter': '按工作区筛选', 'sessionsOverview.allWorkspaces': '全部', - 'sessionsOverview.searchPlaceholder': '搜索会话…', + 'sessionsOverview.searchPlaceholder': '搜索标题、分支、PR 或 ID…', + 'sessionsOverview.workspaceAll': '全部工作区', + 'sessionsOverview.workspacesSelected': (v) => + `${v?.count ?? 0}/${v?.total ?? 0} 个工作区`, + 'sessionsOverview.statusColumn': '状态', + 'sessionsOverview.statusFilter': '按会话状态筛选', + 'sessionsOverview.filter.all': '全部', + 'sessionsOverview.filter.attention': '待处理', + 'sessionsOverview.filter.running': '运行中', + 'sessionsOverview.filter.idle': '空闲', + 'sessionsOverview.status.idle': '空闲', + 'sessionsOverview.details': (v) => `${v?.name ?? ''}的详情`, + 'sessionsOverview.sessionCount': (v) => `${v?.total ?? 0} 个会话`, 'sessionsOverview.confirmArchiveTitle': '归档会话?', 'sessionsOverview.confirmArchive': (v) => `“${v?.name ?? ''}” 将移至已归档会话。`,