diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx
index f20ce7a..cb982be 100644
--- a/src/client/Sidebar.tsx
+++ b/src/client/Sidebar.tsx
@@ -826,6 +826,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
* strip must never break because a plugin's badge computation failed.
*/
const tabBadgeOf = (tab: SidebarTab): ReactNode => {
+ // Agent-terminal wait indicator (sidebar-internal, deliberately NOT a
+ // TabDescriptor.badge — that API is type-keyed and shared with external
+ // plugins, and cannot address one tab): the agent-terminals push mirrors
+ // the model's live terminal_wait_for into state.agentWaits; an agent tab
+ // whose uuid is waiting shows the hourglass pill.
+ if (isAgentTabId(tab.id)) {
+ const wait = state.agentWaits?.[agentUuidOf(tab.id)]
+ if (wait !== undefined) return {'⏳'}
+ }
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
if (descriptor?.badge === undefined) return null
let value: string | number | null | undefined
diff --git a/tests/agent-wait-badge.spec.tsx b/tests/agent-wait-badge.spec.tsx
new file mode 100644
index 0000000..a92337a
--- /dev/null
+++ b/tests/agent-wait-badge.spec.tsx
@@ -0,0 +1,127 @@
+/**
+ * The agent-terminal wait badge chain, end to end in jsdom: the host's
+ * agent-terminals push (mirroring the real /sidebar/ws/agent-terminals
+ * frames) reconciles tabs AND the wait map; the shell's tabBadgeOf renders
+ * the hourglass pill on the agent tab while a wait is live and drops it
+ * when the push clears. Harness mirrors tests/bottom-auto-terminal.spec.tsx
+ * (real Sidebar shell + fake context + stubbed WebSocket).
+ */
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { createElement } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { act } from 'react-dom/test-utils'
+import { setupReactAct } from './test-utils.ts'
+setupReactAct()
+
+import { Sidebar } from '../src/client/Sidebar.tsx'
+import { createSidebarStore, openTabInActivePane } from '../src/client/state.ts'
+import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
+
+/** jsdom has no WebSocket; the agent-terminals push effect constructs one on mount. */
+class FakeWebSocket {
+ static instances: FakeWebSocket[] = []
+ onmessage: ((event: { data: unknown }) => void) | null = null
+ onclose: (() => void) | null = null
+ onerror: (() => void) | null = null
+ close = (): void => {}
+ constructor(readonly url: string) {
+ FakeWebSocket.instances.push(this)
+ }
+}
+
+/** Unique per-test session ids (see the comment inside mountSidebar). */
+let sessionSeq = 0
+
+function mountSidebar(): { container: HTMLDivElement; store: ReturnType; service: BetterSidebarService; unmount: () => void } {
+ vi.stubGlobal('WebSocket', FakeWebSocket)
+ FakeWebSocket.instances = []
+ const container = document.createElement('div')
+ document.body.append(container)
+ const store = createSidebarStore()
+ const service = createBetterSidebarService(store)
+ // Replace the terminal descriptor with a stub: the real one lazy-loads the
+ // xterm chunk, which jsdom cannot fetch — the badge under test renders in
+ // the TAB STRIP, independent of the view component.
+ service.registerTab({
+ id: 'terminal',
+ title: () => 'Terminal',
+ component: () => null,
+ })
+ // Unique session per test — the store persists per-session state to
+ // localStorage (200ms debounce); a shared id lets a previous test's late
+ // write leak into this store's setSession restore.
+ const sessionId = `s1-${++sessionSeq}`
+ store.setSession(sessionId)
+ const localeSnapshot = { active: 'en' }
+ const sessionsSnapshot = {
+ current: sessionId,
+ byId: { [sessionId]: { cwd: '/tmp' } },
+ }
+ const ctx = {
+ locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
+ sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
+ betterSidebar: service,
+ get: (name: string) => name === 'betterSidebar' ? service : undefined,
+ }
+ const root: Root = createRoot(container)
+ act(() => { root.render(createElement(Sidebar, { ctx: ctx as never, store })) })
+ return {
+ container,
+ store,
+ service,
+ unmount: () => {
+ act(() => { root.unmount() })
+ container.remove()
+ },
+ }
+}
+
+afterEach(() => {
+ document.body.innerHTML = ''
+ // Belt and braces (same as bottom-auto-terminal): drop any persisted layout
+ // a pending 200ms debounce write left behind between tests. Guarded — some
+ // node/jsdom combos (node >=22 without --localstorage-file) expose no
+ // working localStorage and a bare access would throw in the hook.
+ if (typeof localStorage !== 'undefined') localStorage.clear()
+ vi.unstubAllGlobals()
+})
+
+/** The agent-terminals push socket the shell opened for the current session. */
+function feedsSocket(): FakeWebSocket {
+ const socket = FakeWebSocket.instances.find(candidate => candidate.url.includes('/sidebar/ws/agent-terminals'))
+ if (socket === undefined) throw new Error('agent-terminals socket not connected')
+ return socket
+}
+
+describe('agent terminal wait badge (push → state → tab pill)', () => {
+ it('shows ⏳ on the agent tab while a wait is active and drops it when it ends', () => {
+ const { container } = mountSidebar()
+ const terminal = { uuid: 'u1', title: 'dev server', command: '', exited: false }
+ // First push: the agent tab lands in the active pane.
+ act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([terminal]) }) })
+ expect(container.textContent).toContain('dev server')
+ expect(container.textContent).not.toContain('⏳')
+ // Second push carries a live wait → the hourglass pill appears.
+ act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([{ ...terminal, waiting: { needle: 'READY_1', since: 1 } }]) }) })
+ expect(container.textContent).toContain('⏳')
+ // The wait resolved (skip/exit/abort all converge to a waiting-less push).
+ act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([terminal]) }) })
+ expect(container.textContent).not.toContain('⏳')
+ })
+
+ it('shows ⏳ only on agent tabs, never on UI-owned terminal tabs', () => {
+ const { container, store } = mountSidebar()
+ // A UI-owned terminal tab (id NOT agent:) lives in the strip too; the
+ // badge lookup keys on the agent uuid, so it never badges.
+ act(() => { store.reduce(s => openTabInActivePane(s, { id: 'terminal:manual-1', type: 'terminal', title: 'UI terminal' })) })
+ const terminal = { uuid: 'u1', title: 'dev server', command: '', exited: false }
+ act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([{ ...terminal, waiting: { needle: 'READY_1', since: 1 } }]) }) })
+ // Exactly ONE hourglass: the agent tab. (Counted from textContent — CSS
+ // module class names are not stable under the test transform.)
+ expect((container.textContent?.match(/⏳/g) ?? []).length).toBe(1)
+ // The wait resolved → the pill disappears entirely.
+ act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([terminal]) }) })
+ expect(container.textContent).not.toContain('⏳')
+ })
+})