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 (
+
+
+ warning
+
+
+ )
+}
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 (
+
+ )}
+ >
+ {count > 9 ? '9+' : count}
+
+
+ )
+}
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 && (
-