From b3ebe52c3e824b92b8d2e3ac3026072af5d77ba8 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Wed, 9 Sep 2026 13:43:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(sidebar):=20agent=20=E7=BB=88=E7=AB=AF=20t?= =?UTF-8?q?ab=20=E6=A0=87=E9=A2=98=E5=9C=A8=E7=AD=89=E5=BE=85=E6=97=B6?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=20=E2=8F=B3=20=E5=BE=BD=E7=AB=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/Sidebar.tsx | 9 +++ tests/agent-wait-badge.spec.tsx | 127 ++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 tests/agent-wait-badge.spec.tsx 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('⏳') + }) +})