From 965c72bd4d71550c71afc8a045c5ddd52f5fba96 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 7 Sep 2026 10:42:21 +0800 Subject: [PATCH 1/6] feat(web-shell): improve session overview navigation and details --- docs/design/web-shell-session-overview.md | 51 ++ .../SessionOverviewPanel.module.css | 4 + .../components/SessionOverviewPanel.test.tsx | 495 ++++++------ .../components/SessionOverviewPanel.tsx | 711 +++++++++--------- .../sidebar/SessionDetailsTooltip.test.tsx | 123 ++- .../sidebar/SessionDetailsTooltip.tsx | 100 ++- .../e2e/web-shell.session-overview.spec.ts | 234 ++++++ packages/web-shell/client/i18n.tsx | 28 +- 8 files changed, 1138 insertions(+), 608 deletions(-) create mode 100644 docs/design/web-shell-session-overview.md create mode 100644 packages/web-shell/client/e2e/web-shell.session-overview.spec.ts diff --git a/docs/design/web-shell-session-overview.md b/docs/design/web-shell-session-overview.md new file mode 100644 index 00000000000..dcc2ea54bd0 --- /dev/null +++ b/docs/design/web-shell-session-overview.md @@ -0,0 +1,51 @@ +# 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. + 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. +- 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. +- 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/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 7743d6f75cc..dc6c4ecdbfb 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx @@ -381,6 +381,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 = [ @@ -509,6 +517,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"]'); @@ -540,45 +621,55 @@ 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 idle = rows().find((tr) => tr.textContent?.includes('Still'))!; - expect(idle.querySelector('[data-web-shell-session-loading]')).toBeNull(); + 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')).not.toBeNull(); + expect(states[1]?.querySelector('svg')).not.toBeNull(); + expect(states[0]?.querySelector('svg')?.innerHTML).not.toBe( + states[1]?.querySelector('svg')?.innerHTML, + ); }); - it('toggles selection when the row is clicked', () => { + 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(rowCheckbox(rows()[0]!).getAttribute('data-state')).toBe('checked'); - expect(onOpenSession).not.toHaveBeenCalled(); - - 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 the title keyboard-focusable for opening a session', () => { @@ -593,81 +684,110 @@ describe('SessionOverviewPanel', () => { ); }); - 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('keeps the session ID out of the table and offers a details button', async () => { sessionsState.sessions = [ - session('s1', { workspaceCwd: '/workspace/with/a/long/path' }), + session('session-id-for-details', { displayName: 'Details' }), ]; - 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(); - } + 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 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('shows worktree metadata in a column immediately after the title', () => { + it('keeps workspace, branch and PR together below the session title', () => { sessionsState.sessions = [ session('s1', { displayName: 'One', @@ -686,100 +806,62 @@ 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'); - expect( - rows()[0] - ?.querySelector('[data-web-shell-session-title]') - ?.closest('td') - ?.querySelector('a'), - ).toBeNull(); - expect( - rows()[1]?.querySelector('[data-web-shell-session-git]')?.textContent, - ).toBe('-'); + titleCell?.querySelector('[data-web-shell-session-workspace]') + ?.textContent, + ).toBe('w'); + expect(titleCell?.querySelector('a')?.textContent).toBe('#123 +2'); + expect(rows()[1]?.querySelector('[data-web-shell-session-git]')).toBeNull(); }); - it('shows the sidebar details popover from the worktree column', async () => { - vi.useFakeTimers(); - try { - sessionsState.sessions = [ - session('session-details', { - displayName: 'Detailed session', - workspaceCwd: '/work/qwen-code', - updatedAt: '2026-08-26T09:00:00.000Z', - clientCount: 2, - worktree: { - slug: 'details', - path: '/work/qwen-code/.worktrees/details', - branch: 'worktree/details', - }, - prs: [ - { number: 121, url: 'https://github.com/o/r/pull/121' }, - { number: 123, url: 'https://github.com/o/r/pull/123' }, - ], - }), - ]; - render(); - const trigger = container! - .querySelector('[data-web-shell-session-git]')! - .closest('div')!; - await act(async () => { - trigger.dispatchEvent(new Event('pointerover', { bubbles: true })); - vi.advanceTimersByTime(300); - await Promise.resolve(); - }); - - const details = document.querySelector('[role="dialog"]'); - expect(details?.getAttribute('data-align')).toBe('center'); - expect(details?.textContent).toContain('worktree/details'); - expect(details?.textContent).toContain('Pull Request #123'); - expect(details?.textContent).toContain('Pull Request #121'); - expect(details?.textContent).not.toContain('Detailed session'); - expect(details?.textContent).not.toContain('qwen-code'); - expect(details?.textContent).not.toContain('session-details'); - expect(details?.textContent).not.toContain('2 client(s)'); - expect( - details?.querySelectorAll('a[href*="/pull/"]')[0]?.getAttribute('href'), - ).toBe('https://github.com/o/r/pull/123'); - } finally { - vi.useRealTimers(); - } + it('does not 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', + ); }); - it('does not show an empty worktree popover', async () => { - vi.useFakeTimers(); - try { - sessionsState.sessions = [session('no-worktree')]; - render(); - const trigger = container!.querySelector('[data-web-shell-session-git]')!; - await act(async () => { - trigger.dispatchEvent(new Event('pointerover', { bubbles: true })); - vi.advanceTimersByTime(300); - await Promise.resolve(); - }); - - expect(document.querySelector('[role="dialog"]')).toBeNull(); - } finally { - vi.useRealTimers(); - } + it('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', { @@ -787,30 +869,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(); } @@ -883,24 +962,23 @@ 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(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(footerButton('Next')).not.toBeNull(); }); it('opens the selected sessions as a split in ONE new tab (?split=…)', () => { @@ -1100,7 +1178,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]'); @@ -1219,7 +1297,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'); @@ -1265,7 +1343,8 @@ describe('SessionOverviewPanel', () => { const trigger = container!.querySelector( '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.querySelector('.lucide-funnel')).not.toBeNull(); act(() => click(trigger)); const filterPanel = document.querySelector( @@ -1474,41 +1553,31 @@ 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', - ); - const workspaceHeader = headers.find((header) => - header.textContent?.includes('Workspace'), + expect(headers.some((header) => header.textContent === 'Session ID')).toBe( + false, ); - const sessionIdColumnIndex = headers.indexOf(sessionIdHeader!); - expect((gitHeader as HTMLElement).style.width).toBe('144px'); - expect(cells[2]?.firstElementChild?.className).toContain('truncate'); - expect((sessionIdHeader as HTMLElement).style.width).toBe('136px'); - expect((cells[sessionIdColumnIndex] as HTMLElement).style.width).toBe( - '136px', + expect(headers.some((header) => header.textContent === 'Worktree')).toBe( + false, ); - expect((workspaceHeader as HTMLElement).style.width).toBe('128px'); - expect((timeHeader as HTMLElement).style.width).toBe('112px'); + expect((headers[2] as HTMLElement).style.width).toBe('144px'); + 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'); @@ -1521,7 +1590,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'); @@ -1529,14 +1598,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'); @@ -1590,9 +1655,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 8d6541c30f6..b7fbcbb432a 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.tsx @@ -21,8 +21,10 @@ import type { import { ArchiveIcon, ArrowUpDownIcon, - CheckIcon, - CopyIcon, + CircleIcon, + CircleHelpIcon, + InfoIcon, + ShieldQuestionIcon, DownloadIcon, FunnelIcon, PenLineIcon, @@ -43,12 +45,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 +71,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 +95,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 +135,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 { @@ -483,6 +434,7 @@ 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()); @@ -547,7 +499,7 @@ function SessionOverviewPanelInner({ }); }, [excludedWorkspaceCwds, workspaceOptions]); - const filteredCards = useMemo(() => { + const searchedCards = useMemo(() => { let list = cards; if (excludedWorkspaceCwds.size > 0) { list = list.filter( @@ -559,11 +511,33 @@ 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) => ({ + 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, t], + ); const isPrimaryCard = useCallback( (card: SessionCard) => { @@ -1017,7 +991,7 @@ function SessionOverviewPanelInner({ prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }, ); setRowSelection({}); - }, [excludedWorkspaceCwds, searchQuery]); + }, [excludedWorkspaceCwds, searchQuery, statusFilter]); useEffect(() => { const validIds = new Set(filteredCards.map(getSessionIdentity)); setRowSelection((prev) => { @@ -1077,46 +1051,46 @@ function SessionOverviewPanelInner({ cell: ({ row }) => { const card = row.original; return ( -
- {card.color && ( - ), meta: { - width: 112, + width: 96, fluidWeight: 5, } satisfies DataTableColumnMeta, }, @@ -1367,6 +1223,20 @@ function SessionOverviewPanelInner({ const canExport = canExportCard(card); return (
+ + + {(isCurrentCard(card) || sessionMetadataEnabled) && (
+ {workspaceOptions.length > 1 && ( + + + + + + +
+ {workspaceOptions.map((option, index) => { + const id = `session-overview-workspace-${index}`; + return ( + + ); + })} +
+
+
+ )} + ))} +
{popupBlocked && (
@@ -1611,7 +1595,9 @@ function SessionOverviewPanelInner({ } className={styles.tableViewport} rowClassName="cursor-pointer" - onRowClick={(row) => row.toggleSelected()} + onRowClick={(row) => + onOpenSession(row.original.sessionId, row.original.workspaceCwd) + } data-web-shell-session-table-viewport /> @@ -1626,71 +1612,78 @@ function SessionOverviewPanelInner({ data-web-shell-session-footer > - {t('sessionsOverview.selectedRows', { - count: selectedCount, - total: filteredCards.length, - })} - -
- - - - {onOpenSplit && ( + total: filteredCards.length, + }, + )} + + {selectedCount > 0 && ( +
+ + - )} -
+ {onOpenSplit && ( + + )} +
+ )} { }); describe('SessionDetailsTooltip', () => { + it.each([ + [ + { + hasActivePrompt: true, + isWaitingForPermission: true, + isWaitingForUserQuestion: true, + }, + 'Needs approval', + ], + [ + { hasActivePrompt: true, isWaitingForUserQuestion: true }, + 'User input needed', + ], + [{ hasActivePrompt: true }, 'Running'], + ])( + '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(); + }); + 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('shows the same structured details on row hover', 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 7d229453f9d..6fc31e68d1e 100644 --- a/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx +++ b/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx @@ -11,8 +11,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,6 +32,7 @@ interface SessionDetailsTooltipProps { time: string; completedUnread: boolean; worktreeOnly?: boolean; + openOnClick?: boolean; children: ReactElement; } @@ -37,6 +42,7 @@ export function SessionDetailsTooltip({ time, completedUnread, worktreeOnly = false, + openOnClick = false, children, }: SessionDetailsTooltipProps) { const { t } = useI18n(); @@ -49,14 +55,15 @@ export function SessionDetailsTooltip({ const copyResetTimerRef = useRef(undefined); const openTimerRef = useRef(undefined); const closeTimerRef = useRef(undefined); - const anchorRef = useRef(null); + const anchorRef = useRef(null); const collisionBoundary = open ? resolveSessionDetailsCollisionBoundary( - anchorRef.current?.closest('aside') ?? null, + anchorRef.current?.closest('[data-web-shell-root]') ?? + anchorRef.current?.closest('aside') ?? + null, ) : null; const folderPath = session.workspaceCwd; - const folderName = workspaceBasename(folderPath); const branch = session.worktree?.branch ?? session.branch?.name; const prs = [...(session.prs ?? [])] .reverse() @@ -71,11 +78,15 @@ export function SessionDetailsTooltip({ !seenIssueUrls.has(issue.url) && seenIssueUrls.add(issue.url), ); - const status = session.hasActivePrompt - ? t('sidebar.running') - : 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') + : completedUnread + ? t('sidebar.completedUnread') + : `${t('sessionsOverview.status.idle')} · ${t('sidebar.clients', { count: session.clientCount ?? 0 })}`; useEffect(() => { return () => { @@ -119,20 +130,33 @@ export function SessionDetailsTooltip({ 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} + onOpenAutoFocus={(event) => { + if (!openOnClick) event.preventDefault(); + }} + onPointerEnter={openOnClick ? undefined : cancelClose} + onPointerLeave={openOnClick ? undefined : closeAfterDelay} + onClick={(event) => event.stopPropagation()} className={styles.sessionDetailsTooltip} > {!worktreeOnly && ( <>
- + {label} {time && ( @@ -160,7 +189,9 @@ export function SessionDetailsTooltip({
)} @@ -233,11 +264,18 @@ export function SessionDetailsTooltip({ {status}
- {session.sessionId} + + {session.sessionId} +
+ ); + })} + {issues.map((issue, index) => { + const stateLabel = sessionIssueStateLabel(t, issue.state); + return ( +
- {copyStatus === 'copied' ? ( -
- - )} + + { + event.stopPropagation(); + openExternalLink(event, issue.url); + }} + > + {t('sidebar.sessionIssue', { number: issue.number })} + {stateLabel ? ( + {` · ${stateLabel}`} + ) : null} + + + ); + })} + {!worktreeOnly && ( + <> +
+
+
+ + {session.sessionId} + + + + {copyStatus === 'copied' + ? t('sidebar.sessionIdCopied') + : copyStatus === 'failed' + ? t('sidebar.copySessionIdFailed') + : ''} + +
+ + )} + ); 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..8e4ef664970 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 @@ -81,12 +81,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,6 +106,8 @@ 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(); } @@ -113,15 +115,22 @@ test('keeps long session details inside a constrained WebShell @smoke', async ({ const style = window.getComputedStyle(element); return { clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, lineHeight: Number.parseFloat(style.lineHeight), clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, }; }); - expect(titleMetrics.clientHeight).toBeLessThanOrEqual( - titleMetrics.lineHeight + 1, + expect(titleMetrics.clientHeight).toBeGreaterThan(titleMetrics.lineHeight); + expect(titleMetrics.scrollHeight).toBeLessThanOrEqual( + titleMetrics.clientHeight + 1, ); - expect(titleMetrics.scrollWidth).toBeGreaterThan(titleMetrics.clientWidth); + expect(titleMetrics.scrollWidth).toBeLessThanOrEqual( + titleMetrics.clientWidth + 1, + ); + await copyAction.click(); + await expect(details).toContainText('Session ID copied'); + await expectDetailsInsideRoot(webShellRoot, details); }); async function expectDetailsInsideRoot( 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 index 5812175741f..988496198ea 100644 --- a/packages/web-shell/client/e2e/web-shell.session-overview.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.session-overview.spec.ts @@ -85,7 +85,7 @@ test.beforeEach(async ({ page }, testInfo) => { ).toBeVisible(); }); -test('compact overview distinguishes states and filters checkbox selection', async ({ +test('compact overview distinguishes states and filters checkbox selection @smoke', async ({ page, }) => { const panel = page.locator('[data-web-shell-session-panel]'); @@ -154,7 +154,7 @@ test('compact overview distinguishes states and filters checkbox selection', asy await expect(panel.locator('[data-web-shell-session-title]')).toHaveCount(4); }); -test('title hover exposes full metadata, permits copy, and links keep overview open', async ({ +test('title hover exposes full metadata, permits copy, and links keep overview open @smoke', async ({ page, context, }) => { @@ -198,7 +198,7 @@ test('title hover exposes full metadata, permits copy, and links keep overview o ).toHaveAttribute('href', 'https://github.com/example/repo/issues/1234'); }); -test('details button supports keyboard and Escape restores focus without navigating', async ({ +test('details button supports keyboard and Escape restores focus without navigating @smoke', async ({ page, }) => { const panel = page.locator('[data-web-shell-session-panel]'); @@ -225,7 +225,7 @@ test('details button supports keyboard and Escape restores focus without navigat await expect(dialog).toHaveCount(0); }); -test('row opens a session without selecting it', async ({ page }) => { +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(); From f21e69c57ef94e79c7b3a13fc76e015159e44728 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Tue, 8 Sep 2026 02:34:31 +0800 Subject: [PATCH 3/6] test(web-shell): Verify settled session details replacement --- .../client/e2e/web-shell.session-overview.spec.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 index 21a27ffd4b0..4e940f0ebaf 100644 --- a/packages/web-shell/client/e2e/web-shell.session-overview.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.session-overview.spec.ts @@ -292,14 +292,19 @@ test('replaces clicked details when hovering another entry @smoke', async ({ }) => { 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')).toHaveAttribute( - 'aria-label', - 'Approve fixture', - ); + await expect( + page.getByRole('dialog', { name: 'Approve fixture', exact: true }), + ).toHaveAttribute('data-state', 'open'); await page.mouse.move(0, 0); await expect(page.getByRole('dialog')).toHaveCount(0); }); From 932b37f0404418546209374eab1c4e76caa0031f Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Tue, 8 Sep 2026 08:39:05 +0800 Subject: [PATCH 4/6] fix(web-shell): Preserve overview state and keyboard focus --- docs/design/web-shell-session-overview.md | 9 + .../components/SessionOverviewPanel.test.tsx | 188 ++++++++++++++++++ .../components/SessionOverviewPanel.tsx | 35 +++- .../sidebar/SessionDetailsTooltip.tsx | 14 +- .../client/e2e/visuals/screenshots.spec.ts | 21 +- ...web-shell.collapsed-groups-persist.spec.ts | 47 +++-- .../e2e/web-shell.session-overview.spec.ts | 114 +++++++++++ 7 files changed, 406 insertions(+), 22 deletions(-) diff --git a/docs/design/web-shell-session-overview.md b/docs/design/web-shell-session-overview.md index 505925c95ac..be062f717f4 100644 --- a/docs/design/web-shell-session-overview.md +++ b/docs/design/web-shell-session-overview.md @@ -16,6 +16,8 @@ uses only its Git-specific variant. - 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 @@ -23,6 +25,9 @@ uses only its Git-specific variant. 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, 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 @@ -30,6 +35,10 @@ uses only its Git-specific variant. 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. + 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. diff --git a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx index 37787f095a1..f9a978c0ed6 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx @@ -711,6 +711,15 @@ describe('SessionOverviewPanel', () => { 'input[aria-label="Rename: One"]', )!; act(() => setInputValue(input, 'Renamed')); + for (const target of [input, rowActionButton(rows()[0]!, 'Rename')]) { + const mouseDown = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + button: 0, + }); + act(() => target.dispatchEvent(mouseDown)); + expect(mouseDown.defaultPrevented).toBe(false); + } const cell = rows()[0]!.querySelectorAll('td')[2]!; const mouseDown = new MouseEvent('mousedown', { bubbles: true, @@ -720,6 +729,7 @@ describe('SessionOverviewPanel', () => { 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); @@ -745,16 +755,20 @@ describe('SessionOverviewPanel', () => { 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(); @@ -762,6 +776,171 @@ describe('SessionOverviewPanel', () => { }, ); + 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) => { @@ -987,6 +1166,14 @@ describe('SessionOverviewPanel', () => { ?.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(); }); @@ -1747,6 +1934,7 @@ describe('SessionOverviewPanel', () => { false, ); expect((headers[2] as HTMLElement).style.width).toBe('144px'); + expect(cells[2]?.firstElementChild?.className).toContain('truncate'); expect((timeHeader as HTMLElement).style.width).toBe('96px'); const timeSortButton = timeHeader?.querySelector('button'); expect(timeSortButton?.className).toContain('px-0'); diff --git a/packages/web-shell/client/components/SessionOverviewPanel.tsx b/packages/web-shell/client/components/SessionOverviewPanel.tsx index e7f19059ca8..7da2dc52492 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.tsx @@ -765,6 +765,7 @@ function SessionOverviewPanelInner({ const startRename = useCallback((card: SessionCard) => { setActionError(null); + setDetailsOpen(null); setEditingCard(card); setEditingName(card.label); }, []); @@ -1110,6 +1111,28 @@ function SessionOverviewPanelInner({ ) : (
+ {card.status !== 'idle' && ( +