diff --git a/desktop/src/components/layout/AppShell.test.tsx b/desktop/src/components/layout/AppShell.test.tsx index a4699c9e..612436d3 100644 --- a/desktop/src/components/layout/AppShell.test.tsx +++ b/desktop/src/components/layout/AppShell.test.tsx @@ -122,6 +122,15 @@ vi.mock('./ContentRouter', () => ({ ContentRouter: () =>
content loaded
, })) +// The real one subscribes to the chat store, which this file replaces with a +// `{ getState }` stub. Its behaviour has its own test; here only the wiring — +// which session it is told is on screen, and where it sits — is checked. +vi.mock('./MobileAttentionDot', () => ({ + MobileAttentionDot: ({ activeSessionId }: { activeSessionId: string | null }) => ( + + ), +})) + vi.mock('./TabBar', () => ({ TabBar: () => , })) @@ -666,6 +675,22 @@ describe('AppShell boot flow', () => { // platform minimum for primary touch targets is 44, not the 40 it shipped // at (IconButton's own size doc had the two tiers reversed). expect(screen.getByTestId('mobile-sidebar-toggle')).toHaveClass('h-11', 'w-11') + + // The hamburger's corner is where the drawer's waiting marks get announced + // from, and the dot is told which session is on screen so it can leave that + // one out. It shares a wrapper with the button so it can sit on its corner. + const dot = screen.getByTestId('mobile-attention-dot-stub') + expect(dot).toHaveAttribute('data-active-session', 'session-mobile') + expect(screen.getByTestId('mobile-sidebar-toggle').parentElement).toContainElement(dot) + }) + + it('does not put the waiting dot on a desktop window, which has the tab strip instead', async () => { + mocks.isMobile = false + + render() + + await screen.findByText('content loaded') + expect(screen.queryByTestId('mobile-attention-dot-stub')).not.toBeInTheDocument() }) it('keeps browser H5 settings active alongside existing chat tabs', async () => { diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index 9237ae62..47cc94c9 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -27,6 +27,7 @@ import { } from '../../stores/projectDisplayNameStore' import { openDesktopNotificationTarget } from '../../lib/desktopNotificationNavigation' import { TabBar } from './TabBar' +import { MobileAttentionDot } from './MobileAttentionDot' import { WorkspaceHeaderProvider } from './WorkspaceHeaderContext' import { StartupErrorView } from './StartupErrorView' import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore' @@ -362,15 +363,20 @@ export function AppShell() { data-testid="mobile-session-header" className="flex shrink-0 items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2" > - + + + {/* 手机没有 tab 栏,抽屉是切换会话的唯一入口,也是等待标志唯一能被 + 找到的地方:别的会话在等人时,在汉堡按钮上提一下。 */} + + {activeTab?.type === 'settings' ? (

{t('sidebar.settings')}

) : isActiveChatTab ? ( diff --git a/desktop/src/components/layout/MobileAttentionDot.test.tsx b/desktop/src/components/layout/MobileAttentionDot.test.tsx new file mode 100644 index 00000000..ff282bc0 --- /dev/null +++ b/desktop/src/components/layout/MobileAttentionDot.test.tsx @@ -0,0 +1,97 @@ +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import '@testing-library/jest-dom' +import { createDefaultSessionState, useChatStore, type PerSessionState } from '../../stores/chatStore' +import { MobileAttentionDot } from './MobileAttentionDot' + +vi.mock('../../i18n', () => ({ + useTranslation: () => (key: string) => key, +})) + +function session(overrides: Partial = {}): PerSessionState { + return { ...createDefaultSessionState(), ...overrides } +} + +const openRequest: Partial = { + pendingPermission: { requestId: 'r1', toolName: 'Bash', toolUseId: 'tu-1', input: {} }, + pendingPermissions: { r1: { requestId: 'r1', toolName: 'Bash', toolUseId: 'tu-1', input: {} } }, +} + +function seed(sessions: Record) { + act(() => { + useChatStore.setState({ sessions }) + }) +} + +describe('MobileAttentionDot', () => { + beforeEach(() => { + useChatStore.setState({ sessions: {} }) + }) + + afterEach(() => { + cleanup() + useChatStore.setState({ sessions: {} }) + }) + + it('renders nothing while no session is waiting', () => { + seed({ a: session(), b: session({ chatState: 'thinking' }) }) + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + + it('lights when a session other than the one on screen is waiting', () => { + seed({ a: session(), b: session(openRequest) }) + render() + + expect(screen.getByTestId('mobile-attention-dot')).toBeInTheDocument() + expect(screen.getByText('sidebar.sessionNeedsAttention')).toHaveClass('sr-only') + }) + + it('stays dark when the only waiting session is the one on screen', () => { + seed({ a: session(openRequest), b: session() }) + const { container } = render() + + // Its card is already in front of the person. + expect(container).toBeEmptyDOMElement() + }) + + it('lights from a page that is not a session, such as Settings', () => { + seed({ a: session(openRequest) }) + render() + + expect(screen.getByTestId('mobile-attention-dot')).toBeInTheDocument() + }) + + it('counts sessions that have no tab, since the drawer lists them all', () => { + // Nothing here mentions tabs: the dot reads the chat store, not the strip. + seed({ a: session(), 'no-tab': session(openRequest) }) + render() + + expect(screen.getByTestId('mobile-attention-dot')).toBeInTheDocument() + }) + + it('does not light for chatState alone, since no card exists for it', () => { + seed({ a: session(), b: session({ chatState: 'permission_pending' }) }) + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + + it('goes out once the request is answered', () => { + seed({ a: session(), b: session(openRequest) }) + render() + expect(screen.getByTestId('mobile-attention-dot')).toBeInTheDocument() + + seed({ a: session(), b: session({ pendingPermission: null, pendingPermissions: {} }) }) + + expect(screen.queryByTestId('mobile-attention-dot')).not.toBeInTheDocument() + }) + + it('is decorative for a screen reader, which gets the hidden text instead', () => { + seed({ a: session(), b: session(openRequest) }) + render() + + expect(screen.getByTestId('mobile-attention-dot')).toHaveAttribute('aria-hidden', 'true') + }) +}) diff --git a/desktop/src/components/layout/MobileAttentionDot.tsx b/desktop/src/components/layout/MobileAttentionDot.tsx new file mode 100644 index 00000000..dd22c0ce --- /dev/null +++ b/desktop/src/components/layout/MobileAttentionDot.tsx @@ -0,0 +1,39 @@ +import { StatusDot } from '@/components/ui/Badge' +import { useTranslation } from '../../i18n' +import { sessionNeedsAttention } from '../../lib/sessionAttention' +import { useChatStore } from '../../stores/chatStore' + +/** + * A dot in the corner of the phone header's hamburger when a session other than + * the one on screen is waiting for the person. + * + * The phone has no tab strip: the sidebar drawer behind that button is the only + * way to change session, so it is the only place the marks on the session rows + * can be found from. On H5 neither a notification click nor `requestAttention` + * exists, so without this the drawer would have to be opened on a guess. + * + * Its own session is left out — the card is already in front of the person — + * and every session counts, not only ones with a tab, because the drawer lists + * them all. It renders inside a `relative` wrapper around the button. + */ +export function MobileAttentionDot({ activeSessionId }: { activeSessionId: string | null }) { + const t = useTranslation() + const waitingElsewhere = useChatStore((state) => + Object.entries(state.sessions).some( + ([sessionId, session]) => sessionId !== activeSessionId && sessionNeedsAttention(session), + )) + + if (!waitingElsewhere) return null + + return ( + <> + + {t('sidebar.sessionNeedsAttention')} + + ) +} diff --git a/desktop/src/components/layout/SessionAttentionMark.test.tsx b/desktop/src/components/layout/SessionAttentionMark.test.tsx new file mode 100644 index 00000000..a70d0ddc --- /dev/null +++ b/desktop/src/components/layout/SessionAttentionMark.test.tsx @@ -0,0 +1,61 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import '@testing-library/jest-dom' +import { SessionAttentionMark } from './SessionAttentionMark' + +afterEach(cleanup) + +describe('SessionAttentionMark', () => { + it('is one image with the given name', () => { + render() + + expect(screen.getByRole('img', { name: 'Waiting for your approval' })).toBeInTheDocument() + }) + + it('repeats the name as a native tooltip', () => { + render() + + expect(screen.getByRole('img')).toHaveAttribute('title', 'Waiting for your approval') + }) + + it('is not a live region, which would promise an announcement it cannot make', () => { + render() + + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + + it('hides the ligature text so a screen reader does not say "warning" beside the label', () => { + const { container } = render() + + const glyph = container.querySelector('.material-symbols-outlined') + expect(glyph).toHaveTextContent('warning') + expect(glyph).toHaveAttribute('aria-hidden', 'true') + }) + + it('draws a filled glyph', () => { + const { container } = render() + + // Outlined at 14px the exclamation mark is a hairline; filled it is a shape. + expect(container.querySelector('.material-symbols-outlined')).toHaveStyle({ + fontVariationSettings: "'FILL' 1", + }) + }) + + it('pulses three times and then holds still instead of blinking for hours', () => { + const { container } = render() + + const glyph = container.querySelector('.material-symbols-outlined') + expect(glyph).toHaveClass('animate-pulse-dot') + // Inline, so it overrides the `infinite` the class carries. + expect(glyph?.style.animationIterationCount).toBe('3') + }) + + it('takes its colour from the darker warning token and hard-codes none', () => { + const { container } = render() + + // `--color-warning` is 2.92:1 on warm-classic's hovered sidebar row, and a + // bare glyph has no text to fall back on; contrast.test.ts measures this token. + expect(screen.getByRole('img').className).toContain('var(--color-on-warning-container)') + expect(container.innerHTML).not.toMatch(/#[0-9a-f]{3,8}\b/i) + }) +}) diff --git a/desktop/src/components/layout/SessionAttentionMark.tsx b/desktop/src/components/layout/SessionAttentionMark.tsx new file mode 100644 index 00000000..47dc8ba4 --- /dev/null +++ b/desktop/src/components/layout/SessionAttentionMark.tsx @@ -0,0 +1,48 @@ +/** + * "This session is waiting for you". The tab strip and both sidebar views draw + * it through this one component, so `sessionNeedsAttention` keeping them in step + * in state is matched by them staying in step in pixels. + * + * A glyph, not a coloured dot. A session parked on a permission card is still + * running as far as `chatState` goes, and the running marker is a dot; warning + * and brand are within 1.4–1.7:1 of each other in most themes, so a dot against + * a dot leaves colour alone to tell "working" from "needs you", which is no + * answer for anyone who cannot rely on it. The filled triangle has a different + * outline from every dot in the app. + * + * It pulses three times on arrival and then holds still. This state can sit + * for hours, and `pulse-dot` dips to 0.3 opacity — a thin glyph would spend half + * of every cycle unreadable, and an animation that never ends is not something + * to leave running beside someone's work (WCAG 2.2.2). Reduced-motion users + * already get the slower 3s cycle from the global rule. + * + * `role="img"`, not `status`: a live region announces changes to its content, + * and an empty span's aria-label is not one, so `status` promised an + * announcement nothing delivers. The inner ligature text is hidden for the same + * reason — left in, a screen reader would say "warning" next to the label. + * + * The colour is `--color-on-warning-container`, the darker of the two warning + * tokens, not `--color-warning`. This is a bare graphic with no text beside it + * to fall back on, so it has to clear 3:1 on every ground it can sit on, and + * `--color-warning` does not: it is 2.92:1 on warm-classic's hovered sidebar + * row. The two are the same colour in the ink themes, where nothing changes. + * `contrast.test.ts` measures all four grounds in all six themes. + */ +export function SessionAttentionMark({ label }: { label: string }) { + return ( + + + + ) +} diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index 5ec9ff57..16178368 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -296,6 +296,13 @@ function makeChatSessionState(overrides: Partial = {}): PerSess } } +// 一条挂起的授权请求,形状与 store 里的一致:`pendingPermission` 是最新一条的镜像, +// `pendingPermissions` 是全集。 +const openRequest: Partial = { + pendingPermission: { requestId: 'r1', toolName: 'Bash', toolUseId: 'tu-1', input: {} }, + pendingPermissions: { r1: { requestId: 'r1', toolName: 'Bash', toolUseId: 'tu-1', input: {} } }, +} + function createDeferred() { let resolve!: (value: T) => void let reject!: (reason?: unknown) => void @@ -572,6 +579,75 @@ describe('Sidebar', () => { expect(screen.getByRole('button', { name: 'Collapse display' })).toBeInTheDocument() }) + describe('a folded project with a session waiting on the user', () => { + // Eleven sessions against a six-row preview: the last two are past the fold. + function seedFoldedProject() { + const base = new Date('2026-05-15T10:00:00.000Z').getTime() + useSessionStore.setState({ + sessions: Array.from({ length: 11 }, (_, index) => ( + makeSession( + `alpha-${index + 1}`, + index === 10 ? 'Alpha waiting' : index === 9 ? 'Alpha folded' : `Alpha ${index + 1}`, + '/workspace/alpha', + new Date(base - index * 1000).toISOString(), + ) + )), + }) + } + + it('keeps the waiting row on screen while its quiet neighbour stays folded away', () => { + seedFoldedProject() + useChatStore.setState({ + sessions: { 'alpha-11': makeChatSessionState({ chatState: 'tool_executing', ...openRequest }) }, + } as Partial>) + + render() + + // On a phone the dot on the menu button points at this row, and there is no + // tab strip to fall back on: a row folded out of the drawer would end the + // signal one step short. + const waitingRow = screen.getByRole('button', { name: /Alpha waiting/ }) + expect(within(waitingRow).getByLabelText('Waiting for your approval')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Alpha folded/ })).not.toBeInTheDocument() + }) + + it('folds the row away again once it stops waiting', () => { + seedFoldedProject() + useChatStore.setState({ + sessions: { 'alpha-11': makeChatSessionState({ chatState: 'tool_executing', ...openRequest }) }, + } as Partial>) + render() + expect(screen.getByRole('button', { name: /Alpha waiting/ })).toBeInTheDocument() + + act(() => { + useChatStore.setState({ + sessions: { + 'alpha-11': makeChatSessionState({ chatState: 'tool_executing', pendingPermission: null, pendingPermissions: {} }), + }, + } as Partial>) + }) + + expect(screen.queryByRole('button', { name: /Alpha waiting/ })).not.toBeInTheDocument() + }) + + it('keeps the open session and a waiting one visible together, in list order', () => { + seedFoldedProject() + useTabStore.setState({ + tabs: [{ sessionId: 'alpha-10', title: 'Alpha folded', type: 'session', status: 'idle' }], + activeTabId: 'alpha-10', + }) + useChatStore.setState({ + sessions: { 'alpha-11': makeChatSessionState({ chatState: 'tool_executing', ...openRequest }) }, + } as Partial>) + + render() + + const rows = screen.getAllByRole('button', { name: /^Alpha / }).map((row) => row.textContent ?? '') + expect(rows.at(-2)).toContain('Alpha folded') + expect(rows.at(-1)).toContain('Alpha waiting') + }) + }) + it('does not show a fold control when a project is at or below the collapse threshold', () => { const base = new Date('2026-05-15T10:00:00.000Z').getTime() useSessionStore.setState({ @@ -2807,7 +2883,9 @@ describe('Sidebar', () => { seedSessions() useChatStore.setState({ sessions: { - 'today-1': makeChatSessionState({ chatState: 'permission_pending' }), + // chatState 故意不是 'permission_pending':`status` 消息会在卡片还开着时 + // 把它改成别的,只看 chatState 的实现在这里会熄灯。 + 'today-1': makeChatSessionState({ chatState: 'tool_executing', ...openRequest }), }, } as Partial>) @@ -2820,6 +2898,71 @@ describe('Sidebar', () => { expect(within(waitingRow).queryByLabelText('Session running')).not.toBeInTheDocument() }) + it('does not call a session waiting from chatState alone, since no card exists for it', async () => { + seedSessions() + useChatStore.setState({ + sessions: { + 'today-1': makeChatSessionState({ chatState: 'permission_pending' }), + }, + } as Partial>) + + render() + await act(async () => { toggleBell() }) + + const runningGroup = screen.getByTestId('sidebar-task-group-running') + const row = within(runningGroup).getByRole('button', { name: /Today Session/ }) + expect(within(row).queryByLabelText('Waiting for your approval')).not.toBeInTheDocument() + expect(within(row).getByLabelText('Session running')).toBeInTheDocument() + }) + + it('marks a waiting session in the project view too, in place of the running spinner', () => { + seedSessions() + // 没有为它开 tab:侧边栏要能标出没有 tab 的会话,tab 栏那边看不到它。 + useChatStore.setState({ + sessions: { + 'today-1': makeChatSessionState({ chatState: 'tool_executing', ...openRequest }), + 'yesterday-1': makeChatSessionState({ chatState: 'thinking' }), + }, + } as Partial>) + + render() + + const waitingRow = screen.getByRole('button', { name: /Today Session/ }) + expect(within(waitingRow).getByLabelText('Waiting for your approval')).toBeInTheDocument() + expect(within(waitingRow).queryByLabelText('Session running')).not.toBeInTheDocument() + + // 只是在跑、没有在等的会话仍然转圈。 + const busyRow = screen.getByRole('button', { name: /^Yesterday Session/ }) + expect(within(busyRow).getByLabelText('Session running')).toBeInTheDocument() + expect(within(busyRow).queryByLabelText('Waiting for your approval')).not.toBeInTheDocument() + }) + + it('hands a project-view row back to the spinner once the request is answered', () => { + seedSessions() + useChatStore.setState({ + sessions: { + 'today-1': makeChatSessionState({ chatState: 'tool_executing', ...openRequest }), + }, + } as Partial>) + + render() + expect(within(screen.getByRole('button', { name: /Today Session/ })).getByLabelText('Waiting for your approval')) + .toBeInTheDocument() + + // 往返:授权被处理后标志要撤掉,会话还在跑,所以回到转圈。 + act(() => { + useChatStore.setState({ + sessions: { + 'today-1': makeChatSessionState({ chatState: 'tool_executing', pendingPermission: null, pendingPermissions: {} }), + }, + } as Partial>) + }) + + const row = screen.getByRole('button', { name: /Today Session/ }) + expect(within(row).queryByLabelText('Waiting for your approval')).not.toBeInTheDocument() + expect(within(row).getByLabelText('Session running')).toBeInTheDocument() + }) + it('lights the bell when the organize menu picks by time, because they are one state', async () => { seedSessions() render() diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 91b16d89..fcc6dc03 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -43,7 +43,9 @@ import { } from '../../api/desktopUiPreferences' import { getDesktopHost } from '../../lib/desktopHost' import { hasRunningBackgroundTasks } from '../../lib/backgroundTasks' +import { collectAttentionIds } from '../../lib/sessionAttention' import { getSessionWorkspaceState, getSessionSeedWorkDir } from '../../lib/sessionWorkspace' +import { SessionAttentionMark } from './SessionAttentionMark' const desktopHost = getDesktopHost() const isDesktopRuntime = desktopHost.isDesktop @@ -297,14 +299,11 @@ export function Sidebar({ return ids }, [chatSessions, tabs]) // 停在权限请求上的会话在 `runningSessionIds` 里也算「没结束」,但它不是在 - // 干活而是在等人。任务视图要把这两种状态分开显示。 - const attentionSessionIds = useMemo(() => { - const ids = new Set() - for (const [sessionId, sessionState] of Object.entries(chatSessions)) { - if (sessionState.chatState === 'permission_pending') ids.add(sessionId) - } - return ids - }, [chatSessions]) + // 干活而是在等人。两个视图都要把这两种状态分开显示。 + // 判定看挂起的请求记录而不是 `chatState`:`status` / `session_state` 消息会在 + // 卡片还开着的时候把 chatState 改掉,按它判定会在有卡的会话上熄灯。tab 栏与 + // 这里共用同一个 `sessionNeedsAttention`,不要各写各的。 + const attentionSessionIds = useMemo(() => new Set(collectAttentionIds(chatSessions)), [chatSessions]) const taskGroups = useMemo(() => { if (!isTaskView) return [] // 隐藏的项目在任务视图里也要隐藏,否则两个视图对「有哪些会话」说法不一致。 @@ -1212,7 +1211,7 @@ export function Sidebar({ const sessionsExpanded = expandedProjectKeys.has(project.key) const visibleItems = projectCollapsed ? [] - : getVisibleProjectSessions(project.sessions, sessionsExpanded, activeTabId) + : getVisibleProjectSessions(project.sessions, sessionsExpanded, activeTabId, attentionSessionIds) const hiddenCount = project.sessions.length - visibleItems.length const projectSessionTotal = projectSessionTotals[project.key] const hasUnloadedSessions = projectSessionTotal === undefined @@ -1418,6 +1417,7 @@ export function Sidebar({ )} , ): SessionListItem[] { if (expanded || sessions.length <= PROJECT_GROUP_VISIBLE_COUNT) return sessions const visible = sessions.slice(0, PROJECT_GROUP_VISIBLE_COUNT) - if (!activeSessionId || visible.some((session) => session.id === activeSessionId)) return visible - - const activeSession = sessions.find((session) => session.id === activeSessionId) - return activeSession ? [...visible, activeSession] : visible + // 折叠掉的行是要人自己去翻的。当前打开的会话不该被翻到看不见,正在等人的会话 + // 更不该:手机上没有 tab 栏,汉堡按钮上的提示点指向的就是抽屉里这一行, + // 折叠起来等于信号在最后一步断了。它们按列表原有顺序排在前几行之后。 + // 只管已经加载进列表的行:侧边栏每个项目只预取最近 + // `SIDEBAR_PROJECT_SESSION_PREVIEW_LIMIT` 条,更早的会话在列表里根本没有行, + // 桌面上由 tab 栏兜底,手机上要展开历史后才看得到。 + const pinned = sessions + .slice(PROJECT_GROUP_VISIBLE_COUNT) + .filter((session) => session.id === activeSessionId || attentionSessionIds.has(session.id)) + return pinned.length > 0 ? [...visible, ...pinned] : visible } function compareSessionsByTimestamp( @@ -2318,11 +2325,13 @@ function ProjectMenuItem({ function SessionRowMeta({ isRunning, + needsAttention, isWorktree, modifiedAt, t, }: { isRunning: boolean + needsAttention: boolean isWorktree: boolean modifiedAt: string t: (key: TranslationKey, params?: Record) => string @@ -2335,7 +2344,14 @@ function SessionRowMeta({ className="ml-auto flex h-5 flex-shrink-0 items-center justify-end gap-1.5 whitespace-nowrap text-[10px] font-medium tabular-nums text-[var(--color-text-tertiary)]" title={updatedLabel} > - {isRunning && ( + {/* 等人比在跑更要紧:停在卡片上的会话按 chatState 也算「在跑」,但转圈会 + 让人以为可以不管它。 */} + {needsAttention && ( + + + + )} + {isRunning && !needsAttention && ( {title} {needsAttention ? ( - - {/* 名字挂在 StatusDot 自己身上:它带 `role="status"`,裸 span 上的 - aria-label 多数读屏并不播报。 */} - + + {/* 与 tab 栏、项目视图共用同一个标志;名字与 title 都在它自己身上。 */} + ) : isRunning ? ( { + it('is named by what it does, not by the number on it', () => { + render() + + expect(screen.getByRole('button', { name: 'Jump to the next waiting session (3 waiting)' })).toBeInTheDocument() + }) + + it('shows how many sessions a press can reach', () => { + render() + + expect(screen.getByTestId('tab-attention-jump')).toHaveTextContent('3') + }) + + it.each([ + [9, '9'], + [10, '9+'], + [42, '9+'], + ])('shows %i as %s so the pill never outgrows the toolbar', (count, shown) => { + render() + + expect(screen.getByTestId('tab-attention-jump')).toHaveTextContent(new RegExp(`^warning${shown.replace('+', '\\+')}$`)) + }) + + it('jumps when pressed', () => { + const onJump = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Jump' })) + + expect(onJump).toHaveBeenCalledTimes(1) + }) + + it('repeats the label as a native tooltip', () => { + render() + + expect(screen.getByRole('button')).toHaveAttribute('title', 'Jump to the next waiting session') + }) + + it('hides the glyph text from a screen reader', () => { + const { container } = render() + + expect(container.querySelector('.material-symbols-outlined')).toHaveAttribute('aria-hidden', 'true') + }) + + it('does not submit a surrounding form', () => { + render() + + expect(screen.getByRole('button')).toHaveAttribute('type', 'button') + }) + + it('is marked interactive so the window drag region never swallows the press', () => { + render() + + expect(screen.getByRole('button')).toHaveClass('tab-bar-interactive') + }) + + it('wears the warning container pair and hard-codes no colour', () => { + const { container } = render() + + expect(container.innerHTML).toContain('var(--color-warning-container)') + expect(container.innerHTML).toContain('var(--color-on-warning-container)') + expect(container.innerHTML).not.toMatch(/#[0-9a-f]{3,8}\b/i) + }) +}) diff --git a/desktop/src/components/layout/TabAttentionJump.tsx b/desktop/src/components/layout/TabAttentionJump.tsx new file mode 100644 index 00000000..a019002d --- /dev/null +++ b/desktop/src/components/layout/TabAttentionJump.tsx @@ -0,0 +1,55 @@ +import { Badge } from '@/components/ui/Badge' + +/** + * "Some sessions are waiting for you — take me to the next one." + * + * The mark on a tab says which tab; this says how many, and turns "find them + * in a strip that scrolls" into one press. A person coming back to a run of + * reviewers all stopped on the same kind of card can clear them one after + * another without hunting for each. + * + * `count` is the waiting sessions other than the one on screen — the places a + * press can actually go — and the caller only renders this when it is above + * zero. The pill is a `Badge` so it borrows the warning container pair that + * the contrast tests already cover, inside a real button that owns the focus + * ring and the press. Static: the pulse is the tab mark's, and a second thing + * breathing at the other end of the strip would only compete with it. + */ +export function TabAttentionJump({ + count, + label, + onJump, +}: { + count: number + label: string + onJump: () => void +}) { + return ( + + ) +} diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index 686e46b7..e9ade84f 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -1,8 +1,8 @@ -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import '@testing-library/jest-dom' import type { PerSessionState } from '../../stores/chatStore' -import type { ChatState, UIMessage } from '../../types/chat' +import type { ChatState, ServerMessage, UIMessage } from '../../types/chat' import type { TeamWorkbenchSessionTimeline, TeamWorkbenchTask, TeamWorkbenchTimeline } from '../../types/team' import type { WorkflowRun } from '../../types/workflow' import { browserHost } from '../../lib/desktopHost/browserHost' @@ -58,7 +58,7 @@ function fireStripResize() { }) } -function makeChatSession(chatState: ChatState): PerSessionState { +function makeChatSession(chatState: ChatState, overrides: Partial = {}): PerSessionState { return { messages: [], chatState, @@ -81,6 +81,7 @@ function makeChatSession(chatState: ChatState): PerSessionState { elapsedTimer: null, composerPrefill: null, composerDraft: null, + ...overrides, } } @@ -144,6 +145,7 @@ vi.mock('../../i18n', () => ({ useTranslation: () => (key: string, params?: Record) => { const translations: Record = { 'sidebar.extensions': 'Extension Market', + 'sidebar.sessionNeedsAttention': 'Waiting for your approval', 'tabs.close': 'Close', 'tabs.closeOthers': 'Close Others', 'tabs.closeLeft': 'Close Left', @@ -167,6 +169,7 @@ vi.mock('../../i18n', () => ({ 'agentTeams.hideReport': 'Hide Agent Teams Run Report', 'tabs.scrollLeft': 'Scroll tabs left', 'tabs.scrollRight': 'Scroll tabs right', + 'tabs.jumpToAttention': 'Jump to the next waiting session ({count} waiting)', 'tabs.closeTab': 'Close {title}', 'tabs.untitled': 'Untitled', 'settings.title': 'Localized Settings', @@ -2926,4 +2929,506 @@ describe('TabBar', () => { expect(screen.getAllByLabelText('Session running')).toHaveLength(3) expect(screen.getByText('Idle').closest('[data-dragging]')?.querySelector('[aria-label="Session running"]')).toBeNull() }) + + describe('a session waiting on the user', () => { + // A tool approval, an AskUserQuestion card and an ExitPlanMode review all + // arrive as `permission_request`; only the tool name differs. + const request = (requestId: string, toolName = 'Bash'): ServerMessage => ({ + type: 'permission_request', + requestId, + toolName, + toolUseId: `tu-${requestId}`, + input: {}, + }) + const resolved = (requestId: string, allowed = true): ServerMessage => ({ + type: 'permission_resolved', + requestId, + permissionType: 'tool', + allowed, + }) + // The store keeps the newest request in `pendingPermission` as a + // compatibility mirror and every outstanding one in `pendingPermissions`. + const waiting = (...requestIds: string[]): Partial => { + const requests = requestIds.map((requestId) => ({ + requestId, + toolName: 'Bash', + toolUseId: `tu-${requestId}`, + input: {}, + })) + return { + pendingPermission: requests.at(-1) ?? null, + pendingPermissions: Object.fromEntries(requests.map((request) => [request.requestId, request])), + } + } + const tabOf = (title: string) => screen.getByText(title).closest('[data-dragging]')! + const attentionMark = (title: string) => + within(tabOf(title)).queryByRole('img', { name: 'Waiting for your approval' }) + const runningMark = (title: string) => within(tabOf(title)).queryByLabelText('Session running') + + async function renderStrip( + titles: Record, + activeTabId: string, + sessions: Record = {}, + ) { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + + useTabStore.setState({ + tabs: Object.entries(titles).map(([sessionId, title]) => ({ + sessionId, + title, + type: 'session' as const, + status: 'idle' as const, + })), + activeTabId, + }) + useChatStore.setState({ + sessions: Object.fromEntries(Object.keys(titles).map((id) => [id, sessions[id] ?? makeChatSession('idle')])), + disconnectSession: vi.fn(), + } as Partial>) + + await act(async () => { + render() + }) + + return { + useChatStore, + useTabStore, + // The real reducer, driven the way the socket drives it. + receive: (sessionId: string, message: ServerMessage) => act(() => { + useChatStore.getState().handleServerMessage(sessionId, message) + }), + } + } + + it('marks the tab while a request is open and gives it back to the running marker once answered', async () => { + const { receive } = await renderStrip({ waiting: 'Waiting', quiet: 'Quiet' }, 'quiet') + + await receive('waiting', request('r1')) + + expect(tabOf('Waiting')).toHaveAttribute('data-attention', 'true') + expect(attentionMark('Waiting')).toBeInTheDocument() + // Parked on a card is "running" by chatState as well. The brand dot said + // the session was working and could be left alone, which is exactly what + // was not true of it, so it must not be drawn beside the mark. + expect(runningMark('Waiting')).toBeNull() + expect(tabOf('Quiet')).toHaveAttribute('data-attention', 'false') + expect(attentionMark('Quiet')).toBeNull() + + await receive('waiting', resolved('r1')) + + expect(tabOf('Waiting')).toHaveAttribute('data-attention', 'false') + expect(attentionMark('Waiting')).toBeNull() + // An allowed tool runs on, so the tab is back to reporting that. + expect(runningMark('Waiting')).toBeInTheDocument() + }) + + it('stays lit when a status message overwrites chatState under a card that is still open', async () => { + const { receive, useChatStore } = await renderStrip({ waiting: 'Waiting' }, 'waiting') + + await receive('waiting', request('r1')) + expect(useChatStore.getState().sessions.waiting?.chatState).toBe('permission_pending') + await receive('waiting', { type: 'status', state: 'tool_executing' }) + + // The precondition that makes this a regression test: a rule that read + // chatState would see "not waiting" from here on, under a live card. + expect(useChatStore.getState().sessions.waiting?.chatState).toBe('tool_executing') + expect(tabOf('Waiting')).toHaveAttribute('data-attention', 'true') + expect(attentionMark('Waiting')).toBeInTheDocument() + }) + + it('does not light for chatState alone, because nothing renders a card for it', async () => { + await renderStrip({ empty: 'Empty' }, 'empty', { empty: makeChatSession('permission_pending') }) + + expect(tabOf('Empty')).toHaveAttribute('data-attention', 'false') + expect(attentionMark('Empty')).toBeNull() + // It is still not idle, so the tab keeps saying it is running. + expect(runningMark('Empty')).toBeInTheDocument() + }) + + it.each(['Bash', 'AskUserQuestion', 'ExitPlanMode'])('lights for a %s request', async (toolName) => { + const { receive } = await renderStrip({ waiting: 'Waiting' }, 'waiting') + + await receive('waiting', request('r1', toolName)) + + expect(attentionMark('Waiting')).toBeInTheDocument() + }) + + it('does not light for a Computer Use request, which has no card to answer', async () => { + const computerUse = { requestId: 'cu-1', request: {} as never } + await renderStrip({ cu: 'Computer use' }, 'cu', { + cu: makeChatSession('idle', { + pendingComputerUsePermission: computerUse, + pendingComputerUsePermissions: { 'cu-1': computerUse }, + }), + }) + + expect(tabOf('Computer use')).toHaveAttribute('data-attention', 'false') + expect(attentionMark('Computer use')).toBeNull() + }) + + it('outranks the running marker for a session that is also mid-turn', async () => { + await renderStrip({ busy: 'Busy' }, 'busy', { busy: makeChatSession('thinking', waiting('r1')) }) + + expect(attentionMark('Busy')).toBeInTheDocument() + expect(runningMark('Busy')).toBeNull() + }) + + it('still shows the error dot for a failed tab that is neither running nor waiting', async () => { + const { useTabStore } = await renderStrip({ failed: 'Failed', quiet: 'Quiet' }, 'quiet') + + act(() => { + useTabStore.getState().updateTabStatus('failed', 'error') + }) + + // The dot is decorative and has no name, so it is found by its danger colour. + expect(tabOf('Failed').querySelector('[class*="--color-error"]')).toBeInTheDocument() + expect(attentionMark('Failed')).toBeNull() + expect(runningMark('Failed')).toBeNull() + expect(tabOf('Quiet').querySelector('[class*="--color-error"]')).toBeNull() + }) + + it('ranks waiting above an earlier failure: the failed turn is over, the card is not', async () => { + const { receive, useTabStore } = await renderStrip({ failed: 'Failed', quiet: 'Quiet' }, 'quiet') + act(() => { + useTabStore.getState().updateTabStatus('failed', 'error') + }) + + await receive('failed', request('rf')) + + expect(attentionMark('Failed')).toBeInTheDocument() + expect(tabOf('Failed').querySelector('[class*="--color-error"]')).toBeNull() + + // And it goes once the card is answered. + await receive('failed', resolved('rf', false)) + expect(attentionMark('Failed')).toBeNull() + }) + + it('lights a background tab and the active tab alike, and only the ones that are waiting', async () => { + const { receive } = await renderStrip({ a: 'Alpha', b: 'Bravo', c: 'Charlie' }, 'a') + + await receive('a', request('ra')) + await receive('c', request('rc')) + + expect(tabOf('Alpha')).toHaveAttribute('data-attention', 'true') + expect(tabOf('Bravo')).toHaveAttribute('data-attention', 'false') + expect(tabOf('Charlie')).toHaveAttribute('data-attention', 'true') + expect(screen.getAllByRole('img', { name: 'Waiting for your approval' })).toHaveLength(2) + }) + + it('stays lit until the last of several requests is answered', async () => { + const { receive } = await renderStrip({ waiting: 'Waiting' }, 'waiting') + + await receive('waiting', request('r1')) + await receive('waiting', request('r2', 'AskUserQuestion')) + await receive('waiting', resolved('r1')) + + expect(attentionMark('Waiting')).toBeInTheDocument() + + await receive('waiting', resolved('r2')) + + expect(attentionMark('Waiting')).toBeNull() + }) + + it('swaps the mark into the same 14px slot the running dot uses, so the title does not move', async () => { + const { receive } = await renderStrip({ waiting: 'Waiting' }, 'waiting') + const slot = () => tabOf('Waiting').firstElementChild as HTMLElement + + await receive('waiting', { type: 'status', state: 'thinking' }) + expect(runningMark('Waiting')).toBeInTheDocument() + expect(slot()).toHaveClass('w-[14px]', 'mr-1.5') + + await receive('waiting', request('r1')) + expect(attentionMark('Waiting')).toBeInTheDocument() + expect(slot()).toHaveClass('w-[14px]', 'mr-1.5') + }) + + it('does not rerender when requests change without changing which tabs are waiting', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + const { useSessionStore } = await import('../../stores/sessionStore') + + useTabStore.setState({ + tabs: [{ sessionId: 'tab-1', title: 'Workspace Session', type: 'session', status: 'idle' }], + activeTabId: 'tab-1', + }) + useChatStore.setState({ + sessions: { + 'tab-1': makeChatSession('idle', waiting('r1')), + ghost: makeChatSession('idle'), + }, + disconnectSession: vi.fn(), + } as Partial>) + useSessionStore.setState({ + sessions: [{ + id: 'tab-1', + title: 'Workspace Session', + createdAt: '2026-05-13T00:00:00.000Z', + modifiedAt: '2026-05-13T00:00:00.000Z', + messageCount: 0, + projectPath: '/repo', + workDir: '/repo/worktree', + workDirExists: true, + }], + activeSessionId: 'tab-1', + }) + + await act(async () => { + render() + }) + expect(openProjectMenuMock.paths[openProjectMenuMock.paths.length - 1]).toBe('/repo/worktree') + + openProjectMenuMock.paths = [] + await act(async () => { + useChatStore.setState((state) => ({ + sessions: { + ...state.sessions, + // Same request, fresh objects: what a replayed permission_request does. + 'tab-1': { ...state.sessions['tab-1']!, ...waiting('r1') }, + // A session with no tab is not this strip's business. + ghost: { ...state.sessions.ghost!, ...waiting('r2') }, + }, + })) + }) + expect(openProjectMenuMock.paths).toEqual([]) + + // Positive control: a change that does alter the set does rerender. + await act(async () => { + useChatStore.getState().handleServerMessage('tab-1', resolved('r1')) + }) + expect(openProjectMenuMock.paths.length).toBeGreaterThan(0) + }) + + describe('scrolled out of view', () => { + // The strip is 840 wide and scrolled to 300, so both chevrons are in. Each + // case moves only the tabs' own rects against that. + async function renderScrolledStrip() { + const view = await renderStrip({ a: 'Alpha', b: 'Bravo', c: 'Charlie', d: 'Delta' }, 'b') + const strip = screen.getByTestId('tab-bar-scroll-region') + stubRect(strip, 0, 840) + Object.defineProperty(strip, 'clientWidth', { configurable: true, get: () => 840 }) + Object.defineProperty(strip, 'scrollWidth', { configurable: true, get: () => 1600 }) + Object.defineProperty(strip, 'scrollLeft', { configurable: true, get: () => 300 }) + Object.defineProperty(strip, 'scrollBy', { configurable: true, value: vi.fn() }) + + const place = (title: string, left: number, right: number) => stubRect(tabOf(title), left, right) + place('Alpha', -400, -260) + place('Bravo', 20, 160) + place('Charlie', 600, 740) + place('Delta', 900, 1040) + const scrolled = () => act(() => { fireEvent.scroll(strip) }) + scrolled() + + return { ...view, place, scrolled } + } + const hintOn = (side: 'left' | 'right') => screen.queryByTestId(`tab-strip-attention-${side}`) + + it('hints on the right chevron when a waiting tab is cut off past the right edge', async () => { + const { receive } = await renderScrolledStrip() + + await receive('d', request('rd')) + + expect(hintOn('right')).toBeInTheDocument() + expect(hintOn('left')).not.toBeInTheDocument() + }) + + it('hints on the left chevron when a waiting tab is cut off past the left edge', async () => { + const { receive } = await renderScrolledStrip() + + await receive('a', request('ra')) + + expect(hintOn('left')).toBeInTheDocument() + expect(hintOn('right')).not.toBeInTheDocument() + }) + + it('hints on both sides when waiting tabs are cut off on both', async () => { + const { receive } = await renderScrolledStrip() + + await receive('a', request('ra')) + await receive('d', request('rd')) + + expect(hintOn('left')).toBeInTheDocument() + expect(hintOn('right')).toBeInTheDocument() + }) + + it('describes the hint to a screen reader without renaming the chevron', async () => { + const { receive } = await renderScrolledStrip() + + await receive('d', request('rd')) + + const chevron = screen.getByRole('button', { name: 'Scroll tabs right' }) + const describedBy = chevron.getAttribute('aria-describedby') + expect(describedBy).toBeTruthy() + expect(document.getElementById(describedBy!)).toHaveTextContent('Waiting for your approval') + expect(chevron).toHaveAttribute('title', 'Waiting for your approval') + // The other chevron has nothing to say and says nothing. + expect(screen.getByRole('button', { name: 'Scroll tabs left' })).not.toHaveAttribute('aria-describedby') + }) + + it('lets go of the hint once the waiting tab is scrolled fully into view', async () => { + const { receive, place, scrolled } = await renderScrolledStrip() + await receive('d', request('rd')) + expect(hintOn('right')).toBeInTheDocument() + + place('Delta', 500, 640) + scrolled() + + expect(hintOn('right')).not.toBeInTheDocument() + }) + + it('says nothing for a tab that is cut off but not waiting', async () => { + const { receive } = await renderScrolledStrip() + + // Someone else is waiting, and in view. Alpha and Delta are out of view + // but idle: being out of view is not what the hint is about. + await receive('c', request('rc')) + + expect(hintOn('left')).not.toBeInTheDocument() + expect(hintOn('right')).not.toBeInTheDocument() + }) + + it('says nothing for a waiting tab that is whole, because its own mark is on screen', async () => { + const { receive } = await renderScrolledStrip() + + await receive('b', request('rb')) + await receive('c', request('rc')) + + expect(hintOn('left')).not.toBeInTheDocument() + expect(hintOn('right')).not.toBeInTheDocument() + }) + + it('gives a subpixel of slack, and no more than that', async () => { + const { receive, place, scrolled } = await renderScrolledStrip() + await receive('d', request('rd')) + + // One pixel past the strip's 840 edge is layout rounding, not a clip. + place('Delta', 700, 841) + scrolled() + expect(hintOn('right')).not.toBeInTheDocument() + + place('Delta', 700, 842) + scrolled() + expect(hintOn('right')).toBeInTheDocument() + }) + + it('follows a request that arrives and is answered while the strip stands still', async () => { + const { receive } = await renderScrolledStrip() + // No scroll and no resize from here on: only the store moves. + expect(hintOn('right')).not.toBeInTheDocument() + + await receive('d', request('rd')) + expect(hintOn('right')).toBeInTheDocument() + + await receive('d', resolved('rd')) + expect(hintOn('right')).not.toBeInTheDocument() + }) + }) + + describe('jumping to the next one', () => { + const jump = () => screen.queryByTestId('tab-attention-jump') + + it('is not offered while nothing is waiting', async () => { + await renderStrip({ a: 'Alpha', b: 'Bravo' }, 'a') + + expect(jump()).not.toBeInTheDocument() + }) + + it('is not offered when the only waiting session is the one on screen', async () => { + const { receive } = await renderStrip({ a: 'Alpha', b: 'Bravo' }, 'a') + + await receive('a', request('ra')) + + // Its own mark is lit, but there is nowhere else to go. + expect(attentionMark('Alpha')).toBeInTheDocument() + expect(jump()).not.toBeInTheDocument() + }) + + it('counts the other waiting sessions and sits with the toolbar buttons', async () => { + const { receive } = await renderStrip({ a: 'Alpha', b: 'Bravo', c: 'Charlie', d: 'Delta' }, 'a') + + await receive('a', request('ra')) + await receive('c', request('rc')) + await receive('d', request('rd')) + + // Alpha is on screen, so it is not somewhere to jump to. + const button = within(screen.getByTestId('workspace-window-header')).getByTestId('tab-attention-jump') + expect(button).toHaveTextContent('2') + expect(button).toHaveAccessibleName('Jump to the next waiting session (2 waiting)') + }) + + it('takes you to the next waiting tab after the active one, in strip order', async () => { + const { receive, useTabStore } = await renderStrip({ a: 'Alpha', b: 'Bravo', c: 'Charlie', d: 'Delta' }, 'a') + await receive('c', request('rc')) + await receive('d', request('rd')) + + fireEvent.click(jump()!) + expect(useTabStore.getState().activeTabId).toBe('c') + + // From Charlie the only other waiting tab is Delta, and then back again. + fireEvent.click(jump()!) + expect(useTabStore.getState().activeTabId).toBe('d') + fireEvent.click(jump()!) + expect(useTabStore.getState().activeTabId).toBe('c') + }) + + it('wraps past the end of the strip', async () => { + const { receive, useTabStore } = await renderStrip({ a: 'Alpha', b: 'Bravo', c: 'Charlie', d: 'Delta' }, 'd') + await receive('a', request('ra')) + await receive('c', request('rc')) + + fireEvent.click(jump()!) + + expect(useTabStore.getState().activeTabId).toBe('a') + }) + + it('brings the tab it lands on into view', async () => { + const { receive } = await renderStrip({ a: 'Alpha', b: 'Bravo', c: 'Charlie' }, 'a') + await receive('c', request('rc')) + scrollIntoViewMock.mockClear() + + fireEvent.click(jump()!) + + expect(scrollIntoViewMock).toHaveBeenCalled() + }) + + it('counts down as requests are answered and leaves with the last one', async () => { + const { receive } = await renderStrip({ a: 'Alpha', b: 'Bravo', c: 'Charlie' }, 'a') + await receive('b', request('rb')) + await receive('c', request('rc')) + expect(jump()).toHaveTextContent('2') + + await receive('b', resolved('rb')) + expect(jump()).toHaveTextContent('1') + + await receive('c', resolved('rc')) + expect(jump()).not.toBeInTheDocument() + }) + + it('is offered from a tab that is not a session, such as Settings', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + useTabStore.setState({ + tabs: [ + { sessionId: '__settings__', title: 'Settings', type: 'settings', status: 'idle' }, + { sessionId: 'a', title: 'Alpha', type: 'session', status: 'idle' }, + ], + activeTabId: '__settings__', + }) + useChatStore.setState({ + sessions: { a: makeChatSession('idle', waiting('ra')) }, + disconnectSession: vi.fn(), + } as Partial>) + await act(async () => { + render() + }) + + fireEvent.click(jump()!) + + expect(useTabStore.getState().activeTabId).toBe('a') + }) + }) + }) }) diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 50a1eefb..6ec187a1 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useMemo, useRef, useState, useEffect, useCallback } from 'react' +import { forwardRef, useMemo, useRef, useState, useEffect, useLayoutEffect, useCallback, useId } from 'react' import { useShallow } from 'zustand/react/shallow' import { SCHEDULED_TAB_ID, @@ -29,6 +29,9 @@ import { useDismissable } from '@/hooks/useDismissable' import { useTranslation } from '../../i18n' import { getDesktopHost } from '../../lib/desktopHost' import { hasRunningBackgroundTasks } from '../../lib/backgroundTasks' +import { collectAttentionIds, nextAttentionSessionId } from '../../lib/sessionAttention' +import { SessionAttentionMark } from './SessionAttentionMark' +import { TabAttentionJump } from './TabAttentionJump' import { WindowControls, showWindowControls } from './WindowControls' import { OpenProjectMenu } from './OpenProjectMenu' import { SquareTerminal } from 'lucide-react' @@ -58,6 +61,26 @@ const REVEAL_ACTIVE_TAB: ScrollIntoViewOptions = { // whose edges land on fractional pixels reports the tab as clipped on every // single resize and re-scrolls forever. const TAB_VISIBILITY_TOLERANCE = 1 + +type ClippedSide = 'left' | 'right' + +/** + * Which edge of the strip a tab is cut off by, or null when it is whole. This + * is the one definition of "whole": re-revealing the active tab and hinting at + * waiting tabs the strip has scrolled away both ask it, so the two cannot + * disagree about what is out of view. A tab that is only partly clipped counts — + * its glyph sits at the left edge, and a hint that sometimes stays quiet for a + * tab the person cannot fully see is worse than one that speaks a little early. + */ +function clippedSide( + strip: Pick, + tab: Pick, +): ClippedSide | null { + if (tab.left < strip.left - TAB_VISIBILITY_TOLERANCE) return 'left' + if (tab.right > strip.right + TAB_VISIBILITY_TOLERANCE) return 'right' + return null +} + // One glyph per *non-chat* tab kind: the glyph says "this tab is not a // conversation". Chat tabs deliberately have none — a bubble on every tab in a // strip that is mostly chats is pure noise, and the slot it occupied is worth @@ -125,6 +148,15 @@ export function TabBar() { (sessionState.chatState !== 'idle' || hasRunningBackgroundTasks(sessionState.backgroundAgentTasks)) }) )) + // Tabs that are stopped on a decision only the user can make. Read from the + // outstanding requests rather than from `chatState` (see + // `sessionNeedsAttention`), and `useShallow` for the same reason as above: a + // fresh array from every store update would loop the subscription. + const attentionList = useChatStore(useShallow((s) => collectAttentionIds(s.sessions, sessionTabIds))) + const attentionSet = useMemo(() => new Set(attentionList), [attentionList]) + // The waiting tabs other than the one on screen: the places a jump can take + // you, and so the number on the button that offers it. + const otherAttentionCount = attentionList.filter((sessionId) => sessionId !== activeTabId).length const disconnectSession = useChatStore((s) => s.disconnectSession) const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) ?? null const isActiveSessionTab = isSessionTab(activeTab) || isSessionTabId(activeTabId) @@ -203,6 +235,14 @@ export function TabBar() { const userScrolledRef = useRef(false) const [canScrollLeft, setCanScrollLeft] = useState(false) const [canScrollRight, setCanScrollRight] = useState(false) + // Whether a waiting tab is scrolled out of view on each side. The mark on the + // tab itself is no use to someone who cannot see the tab, and "tab 多了" is + // exactly when that happens. + const [attentionOffscreen, setAttentionOffscreen] = useState({ left: false, right: false }) + // `updateScrollState` is a stable callback with no dependencies, so it reads + // the waiting tabs through a ref that the layout effect below keeps current. + const attentionListRef = useRef([]) + const attentionHintId = useId() const [tabHitWidth, setTabHitWidth] = useState(0) const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null) const [pendingCloseRequest, setPendingCloseRequest] = useState(null) @@ -239,8 +279,33 @@ export function TabBar() { const last = el.lastElementChild as HTMLElement | null const contentWidth = first && last ? last.offsetLeft + last.offsetWidth - first.offsetLeft : 0 setTabHitWidth(Math.max(0, Math.min(el.clientWidth, contentWidth - el.scrollLeft))) + + // Only the waiting tabs are measured, so a scroll costs a few rect reads + // rather than one per tab. State is left alone when nothing changed: this + // runs on every scroll event, and a fresh object each time would rerender + // the strip for nothing. + const strip = el.getBoundingClientRect() + let left = false + let right = false + for (const sessionId of attentionListRef.current) { + const tabEl = tabRefs.current.get(sessionId) + if (!tabEl) continue + const side = clippedSide(strip, tabEl.getBoundingClientRect()) + if (side === 'left') left = true + else if (side === 'right') right = true + } + setAttentionOffscreen((prev) => (prev.left === left && prev.right === right ? prev : { left, right })) }, []) + // A request can arrive, or be answered, with the strip standing still — no + // scroll, no resize — so nothing in the observer path would notice. Measuring + // in a layout effect keeps the chevron from showing the previous answer for + // a frame, and the tabs are in the DOM by then, so their rects are current. + useLayoutEffect(() => { + attentionListRef.current = attentionList + updateScrollState() + }, [attentionList, tabs, updateScrollState]) + // Keeping the active tab whole is an invariant the strip has to re-establish // after its own width changes, not something a single scroll on activation // can settle. The chevrons are why: they are `w-7` siblings of this region, @@ -276,10 +341,7 @@ export function TabBar() { // Already whole. The tolerance is for subpixel layout, which would // otherwise report a clip on every resize and scroll forever. - if ( - tab.left >= strip.left - TAB_VISIBILITY_TOLERANCE && - tab.right <= strip.right + TAB_VISIBILITY_TOLERANCE - ) return + if (!clippedSide(strip, tab)) return activeTabEl.scrollIntoView(REVEAL_ACTIVE_TAB) }, []) @@ -543,9 +605,34 @@ export function TabBar() { setActiveTab(sessionId) } + // Activation does the rest: the effect on `activeTabId` above scrolls the tab + // into view and hands the strip's position back to it. + const jumpToAttention = () => { + const next = nextAttentionSessionId(sessionTabIds, attentionSet, activeTabId) + if (next) setActiveTab(next) + } + + // The chevron on the side a waiting tab has scrolled off gets a dot in its + // corner. Static on purpose: the pulse belongs to the mark on the tab, and + // several things pulsing out of phase across one strip read as noise. The + // sr-only text is the description, not the name — `aria-label` keeps naming + // the button by what it does. + const attentionHint = (side: ClippedSide) => attentionOffscreen[side] ? ( + <> + + {t('sidebar.sessionNeedsAttention')} + + ) : null + const rightScrollControl = canScrollRight && ( - ) @@ -574,8 +661,9 @@ export function TabBar() {
{canScrollLeft && ( - )} @@ -605,11 +693,13 @@ export function TabBar() { displayTitle={displayTitle} closeLabel={t('tabs.closeTab', { title: displayTitle })} isRunning={runningSessionIds.has(tab.sessionId)} + needsAttention={attentionSet.has(tab.sessionId)} isActive={tab.sessionId === activeTabId} isDragOver={dragOverIndex === index} isDragging={tab.sessionId === draggingSessionId} dragOffsetX={tab.sessionId === draggingSessionId ? dragOffsetX : 0} runningLabel={t('tabs.sessionRunning')} + attentionLabel={t('sidebar.sessionNeedsAttention')} onClick={() => handleTabClick(tab.sessionId)} onClose={() => handleClose(tab.sessionId)} onContextMenu={(e) => handleContextMenu(e, tab.sessionId)} @@ -672,6 +762,13 @@ export function TabBar() { className="flex h-[52px] min-w-0 flex-1" /> ) : null} + {otherAttentionCount > 0 && ( + + )} {showActivityButton && activeTabId && ( )} @@ -800,24 +897,33 @@ const TabItem = forwardRef void onClose: () => void onContextMenu: (e: React.MouseEvent) => void onMouseDown: (event: React.MouseEvent) => void -}>(({ tab, displayTitle, closeLabel, isRunning, isActive, isDragOver, isDragging, dragOffsetX, runningLabel, onClick, onClose, onContextMenu, onMouseDown }, ref) => { +}>(({ tab, displayTitle, closeLabel, isRunning, needsAttention, isActive, isDragOver, isDragging, dragOffsetX, runningLabel, attentionLabel, onClick, onClose, onContextMenu, onMouseDown }, ref) => { // Chat tabs carry no glyph at all; the dot only appears when there is // something to say. Everything else identifies its section with one. + // + // Waiting on the user outranks running. A session parked on a permission card + // is also "running" by `chatState`, so before this the brand dot told the + // person the one thing that was not true of it: that it was working and + // could be left alone. const leadingGlyph = isSessionTab(tab) - ? (isRunning - ? - : tab.status === 'error' - ? - : null) + ? (needsAttention + ? + : isRunning + ? + : tab.status === 'error' + ? + : null) : ( {TAB_TYPE_ICON[tab.type] ?? TAB_TYPE_ICON_FALLBACK} @@ -829,6 +935,7 @@ const TabItem = forwardRef = { 'tabs.hideBrowser': 'ブラウザを非表示', 'tabs.scrollLeft': 'タブを左にスクロール', 'tabs.scrollRight': 'タブを右にスクロール', + 'tabs.jumpToAttention': '次の待機中のセッションへ移動({count} 件)', 'tabs.closeTab': '{title} を閉じる', 'tabs.untitled': '無題', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 89373906..942b128d 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -3698,6 +3698,7 @@ export const kr: Record = { 'tabs.hideBrowser': '브라우저 숨기기', 'tabs.scrollLeft': '탭 왼쪽으로 스크롤', 'tabs.scrollRight': '탭 오른쪽으로 스크롤', + 'tabs.jumpToAttention': '다음 대기 중인 세션으로 이동 ({count}개)', 'tabs.closeTab': '{title} 닫기', 'tabs.untitled': '제목 없음', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 27831c77..f487e024 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -3695,6 +3695,7 @@ export const zh: Record = { 'tabs.hideBrowser': '隱藏瀏覽器', 'tabs.scrollLeft': '分頁向左捲動', 'tabs.scrollRight': '分頁向右捲動', + 'tabs.jumpToAttention': '跳轉到下一個待處理會話(共 {count} 個)', 'tabs.closeTab': '關閉 {title}', 'tabs.untitled': '未命名', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 6cd47d2e..18333e06 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -3694,6 +3694,7 @@ export const zh: Record = { 'tabs.hideBrowser': '隐藏浏览器', 'tabs.scrollLeft': '标签页向左滚动', 'tabs.scrollRight': '标签页向右滚动', + 'tabs.jumpToAttention': '跳转到下一个待处理会话(共 {count} 个)', 'tabs.closeTab': '关闭 {title}', 'tabs.untitled': '未命名', diff --git a/desktop/src/lib/sessionAttention.test.ts b/desktop/src/lib/sessionAttention.test.ts new file mode 100644 index 00000000..328006fe --- /dev/null +++ b/desktop/src/lib/sessionAttention.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { createDefaultSessionState, type PendingPermission, type PerSessionState } from '../stores/chatStore' +import type { ChatState } from '../types/chat' +import { collectAttentionIds, nextAttentionSessionId, sessionNeedsAttention } from './sessionAttention' + +const CHAT_STATES: ChatState[] = [ + 'idle', + 'thinking', + 'compacting', + 'tool_executing', + 'streaming', + 'permission_pending', +] + +function perm(requestId: string, toolName = 'Bash'): PendingPermission { + return { requestId, toolName, toolUseId: `tu-${requestId}`, input: {} } +} + +function session(overrides: Partial = {}): PerSessionState { + return { ...createDefaultSessionState(), ...overrides } +} + +// The store keeps the newest request in `pendingPermission` as a compatibility +// mirror and every outstanding one in `pendingPermissions`. +function waiting(...requests: PendingPermission[]): Partial { + return { + pendingPermission: requests.at(-1) ?? null, + pendingPermissions: Object.fromEntries(requests.map((request) => [request.requestId, request])), + } +} + +describe('sessionNeedsAttention', () => { + it.each(CHAT_STATES)('lights for an outstanding request while chatState is %s', (chatState) => { + // `status` and `session_state` messages overwrite chatState under a card + // that is still open, so the records have to decide, whatever chatState says. + expect(sessionNeedsAttention(session({ chatState, ...waiting(perm('r1')) }))).toBe(true) + }) + + it.each(CHAT_STATES)('stays dark without a request while chatState is %s', (chatState) => { + // Including `permission_pending`: nothing renders a card for it, so a + // marker there would point at an empty tab. + expect(sessionNeedsAttention(session({ chatState }))).toBe(false) + }) + + it('reads a legacy session that only has the singular mirror', () => { + expect(sessionNeedsAttention(session({ pendingPermission: perm('r1'), pendingPermissions: undefined }))).toBe(true) + }) + + it('reads a session that only has the plural set', () => { + expect(sessionNeedsAttention(session({ + pendingPermission: null, + pendingPermissions: { r1: perm('r1') }, + }))).toBe(true) + }) + + it('stays dark for an emptied set with no mirror', () => { + expect(sessionNeedsAttention(session({ pendingPermission: null, pendingPermissions: {} }))).toBe(false) + }) + + it.each(['Bash', 'AskUserQuestion', 'ExitPlanMode'])('lights for a %s request', (toolName) => { + expect(sessionNeedsAttention(session(waiting(perm('r1', toolName))))).toBe(true) + }) + + it('does not count a Computer Use request, which has no card to answer', () => { + expect(sessionNeedsAttention(session({ + pendingComputerUsePermission: { requestId: 'cu-1', request: {} as never }, + pendingComputerUsePermissions: { 'cu-1': { requestId: 'cu-1', request: {} as never } }, + }))).toBe(false) + }) + + it('stays dark for a session the store does not know', () => { + expect(sessionNeedsAttention(undefined)).toBe(false) + }) +}) + +describe('collectAttentionIds', () => { + const sessions = { + a: session(waiting(perm('a1'))), + b: session(), + c: session(waiting(perm('c1', 'AskUserQuestion'))), + } + + it('scans every session by default', () => { + expect(collectAttentionIds(sessions)).toEqual(['a', 'c']) + }) + + it('limits the scan to the ids it is given, in that order', () => { + expect(collectAttentionIds(sessions, ['c', 'b', 'a'])).toEqual(['c', 'a']) + }) + + it('ignores ids the store has no session for', () => { + expect(collectAttentionIds(sessions, ['ghost', 'a'])).toEqual(['a']) + }) +}) + +describe('nextAttentionSessionId', () => { + const order = ['a', 'b', 'c', 'd'] + + it('takes the first waiting session after the active one', () => { + expect(nextAttentionSessionId(order, new Set(['c', 'd']), 'a')).toBe('c') + }) + + it('wraps past the end of the strip', () => { + expect(nextAttentionSessionId(order, new Set(['a', 'b']), 'c')).toBe('a') + }) + + it('never returns the active session, even when it is the only one waiting', () => { + expect(nextAttentionSessionId(order, new Set(['b']), 'b')).toBeNull() + }) + + it('skips the active session when others are waiting too', () => { + expect(nextAttentionSessionId(order, new Set(['b', 'd']), 'b')).toBe('d') + expect(nextAttentionSessionId(order, new Set(['b', 'd']), 'd')).toBe('b') + }) + + it('returns null when nothing is waiting or the strip is empty', () => { + expect(nextAttentionSessionId(order, new Set(), 'a')).toBeNull() + expect(nextAttentionSessionId([], new Set(['a']), null)).toBeNull() + }) + + it('starts from the first tab when the active one is not in the strip', () => { + // Settings, Market and friends are tabs but not sessions. + expect(nextAttentionSessionId(order, new Set(['a', 'c']), '__settings__')).toBe('a') + expect(nextAttentionSessionId(order, new Set(['c']), null)).toBe('c') + }) + + it('ignores waiting ids that have no tab', () => { + expect(nextAttentionSessionId(order, new Set(['ghost']), 'a')).toBeNull() + }) +}) diff --git a/desktop/src/lib/sessionAttention.ts b/desktop/src/lib/sessionAttention.ts new file mode 100644 index 00000000..6c27d2cd --- /dev/null +++ b/desktop/src/lib/sessionAttention.ts @@ -0,0 +1,49 @@ +import { listPendingPermissions, type PerSessionState } from '../stores/chatStore' + +type AttentionSource = Pick + +/** + * Whether a session is parked on a decision only the user can make: a tool + * approval, an AskUserQuestion card or an ExitPlanMode review. All three arrive + * as `permission_request` and land in the same records that PermissionDialog, + * AskUserQuestion and the composer render from, so a marker built on this can + * never disagree with the card behind the tab. + * + * It reads the records, not `chatState`. `status(tool_executing)`, + * `session_state(running)` and `content_start` overwrite `chatState` while the + * request is still open, which switched a state-based marker off under a card + * that was still waiting. Computer Use records are left out on purpose: nothing + * renders them, so counting them would light a marker with no card to answer. + * + * This is the one place that decides. The tab strip, both sidebar views and the + * mobile header all call it; a surface that grows its own rule will drift. + */ +export function sessionNeedsAttention(session: AttentionSource | undefined): boolean { + return listPendingPermissions(session).length > 0 +} + +/** The ids among `ids` (every session by default) that are waiting on the user. */ +export function collectAttentionIds( + sessions: Readonly>, + ids: readonly string[] = Object.keys(sessions), +): string[] { + return ids.filter((id) => sessionNeedsAttention(sessions[id])) +} + +/** + * The next waiting session after `activeId` in strip order, wrapping past the + * end and never returning `activeId` itself. An `activeId` that is not in the + * strip (Settings, a session with no tab) starts from the first entry. + */ +export function nextAttentionSessionId( + orderedIds: readonly string[], + attentionIds: ReadonlySet, + activeId: string | null, +): string | null { + const start = activeId ? orderedIds.indexOf(activeId) : -1 + for (let step = 1; step <= orderedIds.length; step += 1) { + const id = orderedIds[(start + step) % orderedIds.length] + if (id !== undefined && id !== activeId && attentionIds.has(id)) return id + } + return null +} diff --git a/desktop/src/theme/contrast.test.ts b/desktop/src/theme/contrast.test.ts index ff092c45..5491409b 100644 --- a/desktop/src/theme/contrast.test.ts +++ b/desktop/src/theme/contrast.test.ts @@ -310,6 +310,46 @@ describe('link affordance contrast', () => { } }) +describe('waiting-on-you mark', () => { + /** + * `SessionAttentionMark` is a filled triangle and nothing else: no fill + * behind it, no text beside it to carry the meaning if the shape were lost. A + * graphic that conveys state answers to WCAG 1.4.11, so it needs 3:1 against + * every ground it can sit on: the strip's trough (idle tabs, and sidebar rows + * at rest), paper (the active tab), and the fills a sidebar row takes when + * hovered or selected. + * + * It is drawn in `--color-on-warning-container`. The plain `--color-warning` + * measured 2.92:1 on warm-classic's hovered sidebar row and was the reason + * for the switch; the two are the same colour in the ink themes. If a palette + * tweak drops a ground below the line, fix the colour, not this number. + */ + const MARK = '--color-on-warning-container' + const GROUNDS = [ + '--color-surface-sidebar', + '--color-surface', + '--color-sidebar-item-hover', + '--color-sidebar-item-active', + ] as const + + for (const [theme, selectors] of Object.entries(THEME_BLOCKS)) { + for (const ground of GROUNDS) { + it(`keeps the warning triangle visible on ${ground} in ${theme}`, () => { + // Dark's row fills are translucent; composite them on the trough they sit on. + const trough = parseColor(resolve('--color-surface-sidebar', selectors)) + const fill = flatten(parseColor(resolve(ground, selectors)), trough) + const mark = flatten(parseColor(resolve(MARK, selectors)), fill) + const ratio = contrast(mark, fill) + + expect( + Number(ratio.toFixed(2)), + `${theme}: ${MARK} on ${ground} is ${ratio.toFixed(2)}:1, needs ${AA_CONTROL_BOUNDARY}:1`, + ).toBeGreaterThanOrEqual(AA_CONTROL_BOUNDARY) + }) + } + } +}) + describe('tab strip outlines', () => { /** * The tab strip's trough is deliberately the sidebar's own ground rather diff --git a/docs/desktop/sessions.md b/docs/desktop/sessions.md index 682475c0..11e895eb 100644 --- a/docs/desktop/sessions.md +++ b/docs/desktop/sessions.md @@ -15,6 +15,8 @@ order: 1 会话开出来是一个标签页,可以像浏览器一样开很多个并排跑。标签上有小圆点表示这条会话还在运行;关一个正在跑的标签会先问你是「保持运行」还是「停止并关闭」。 +会话停在权限请求、提问或计划审批上等你处理时,标签上的小圆点会换成琥珀色的三角叹号,侧边栏里对应的会话行也一样。它会一直亮到你处理完为止,和系统通知开不开无关。标签多到滚出视野时,那一侧的滚动箭头上会出现一个琥珀色小点;标签栏右侧的数字按钮显示还有几个会话在等你,点一下跳到下一个。手机宽度下没有标签栏,汉堡按钮上的小点表示别的会话在等你,打开侧边栏找带三角的那一行。 + 会话标题下面那行小字是元信息:项目路径、分支、模型。想换项目就新建一条会话,一条会话绑定一个目录。 ## 读懂对话流 diff --git a/docs/en/desktop/sessions.md b/docs/en/desktop/sessions.md index 9fdf5bfb..fff95ee3 100644 --- a/docs/en/desktop/sessions.md +++ b/docs/en/desktop/sessions.md @@ -15,6 +15,8 @@ Click **New session** in the sidebar, or press `⌘N` (`Ctrl+N` on Windows and L Each session opens as a tab, and you can run many side by side. A dot on the tab means that session is still running; closing a running tab asks whether you want to **Keep running** or **Stop and close**. +When a session is stopped on a permission request, a question or a plan review and needs you, the dot on its tab becomes an amber warning triangle, and so does its row in the sidebar. It stays until you have dealt with it, whether or not system notifications are on. When so many tabs are open that some scroll out of view, the scroll arrow on that side gets an amber dot, and the number button on the right of the tab bar shows how many other sessions are waiting; press it to jump to the next one. On a phone there is no tab bar: a dot on the menu button means another session is waiting, and its row in the sidebar carries the triangle. + The small line under the session title is metadata: project path, branch, model. A session is bound to one directory — to work on a different project, start a new session. ## Reading the conversation diff --git a/scripts/quality-gate/desktop-smoke/deterministic.test.ts b/scripts/quality-gate/desktop-smoke/deterministic.test.ts index 6cc2a7c6..cdb02b92 100644 --- a/scripts/quality-gate/desktop-smoke/deterministic.test.ts +++ b/scripts/quality-gate/desktop-smoke/deterministic.test.ts @@ -5,6 +5,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { DESKTOP_UI_SMOKE_ALLOW_SELECTOR, + DESKTOP_UI_SMOKE_ATTENTION_PROBE, + DESKTOP_UI_SMOKE_ATTENTION_TAB, DESKTOP_UI_SMOKE_LOCALE, buildDesktopUiSmokeBootstrap, buildDesktopUiSmokePrompt, @@ -34,6 +36,24 @@ describe('deterministic desktop UI smoke setup', () => { expect(DESKTOP_UI_SMOKE_ALLOW_SELECTOR).toBe('button[aria-label^="Allow: "]') }) + test('matches the waiting-tab marker the desktop actually renders', () => { + const tabBar = readFileSync('desktop/src/components/layout/TabBar.tsx', 'utf8') + const mark = readFileSync('desktop/src/components/layout/SessionAttentionMark.tsx', 'utf8') + const english = readFileSync('desktop/src/i18n/locales/en.ts', 'utf8') + + // Same idea as the approval button above: the selectors are derived from the + // production markup, so a rename fails here before the lane times out waiting. + expect(tabBar).toContain('data-testid="tab-bar"') + expect(tabBar).toContain("data-attention={needsAttention ? 'true' : 'false'}") + expect(mark).toContain('role="img"') + expect(english).toContain("'tabs.sessionRunning': 'Session running'") + expect(DESKTOP_UI_SMOKE_ATTENTION_TAB).toBe('[data-testid="tab-bar"] [data-attention="true"]') + expect(DESKTOP_UI_SMOKE_ATTENTION_PROBE).toContain(DESKTOP_UI_SMOKE_ATTENTION_TAB) + expect(DESKTOP_UI_SMOKE_ATTENTION_PROBE).toContain('[role="img"]') + // The probe must also reject a tab that carries the running dot. + expect(DESKTOP_UI_SMOKE_ATTENTION_PROBE).toContain('!tab.querySelector(\'[aria-label="Session running"]\')') + }) + test('asks the mock runtime to write inside the fixture copy only', () => { const projectDir = '/tmp/fixture-copy' const { target, prompt } = buildDesktopUiSmokePrompt(projectDir) diff --git a/scripts/quality-gate/desktop-smoke/deterministic.ts b/scripts/quality-gate/desktop-smoke/deterministic.ts index ae72099c..352b7fee 100644 --- a/scripts/quality-gate/desktop-smoke/deterministic.ts +++ b/scripts/quality-gate/desktop-smoke/deterministic.ts @@ -47,6 +47,22 @@ const SMOKE_MODEL_ID = 'desktop-ui-smoke-model' export const DESKTOP_UI_SMOKE_LOCALE = 'en' export const DESKTOP_UI_SMOKE_ALLOW_SELECTOR = 'button[aria-label^="Allow: "]' +/** + * The tab of a session stopped on a decision only the user can make. + * `data-attention` is set on the tab by TabBar; the strip is the only place it + * appears, so the selector is anchored there. + */ +export const DESKTOP_UI_SMOKE_ATTENTION_TAB = '[data-testid="tab-bar"] [data-attention="true"]' + +/** + * True while that tab carries the warning mark and has *not* also been given the + * running dot. A session parked on a permission card is "running" by chatState + * too, and the running dot said the one thing that is not true of it. + */ +export const DESKTOP_UI_SMOKE_ATTENTION_PROBE = + `(() => { const tab = document.querySelector('${DESKTOP_UI_SMOKE_ATTENTION_TAB}'); ` + + `return !!tab && !!tab.querySelector('[role="img"]') && !tab.querySelector('[aria-label="Session running"]') })()` + export function buildDesktopUiSmokeBootstrap(sessionId: string) { return [ `localStorage.setItem('cc-haha-locale', ${JSON.stringify(DESKTOP_UI_SMOKE_LOCALE)})`, @@ -262,7 +278,13 @@ export async function executeDeterministicDesktopSmoke( throw new Error('the tool wrote the fixture file before the permission dialog was answered') } await browserStep(['screenshot', join(artifactDir, 'permission-dialog.png')], { allowFailure: true }) + // While the dialog is open the session's tab has to say the session is + // waiting on the person, not running, and has to stop saying it once the + // request is answered. A marker derived from anything but the open request + // gets one of those two wrong. + await browserStep(['wait', '--fn', DESKTOP_UI_SMOKE_ATTENTION_PROBE], { timeoutMs: 15_000 }) await browserStep(['click', DESKTOP_UI_SMOKE_ALLOW_SELECTOR], { timeoutMs: 20_000 }) + await browserStep(['wait', '--fn', `!document.querySelector('${DESKTOP_UI_SMOKE_ATTENTION_TAB}')`], { timeoutMs: 20_000 }) await pollUntil( async () => existsSync(target) && readFileSync(target, 'utf8') === TARGET_CONTENT,