diff --git a/src/client/sidebar/use-host-feeds.ts b/src/client/sidebar/use-host-feeds.ts index 0028690..4207e38 100644 --- a/src/client/sidebar/use-host-feeds.ts +++ b/src/client/sidebar/use-host-feeds.ts @@ -63,7 +63,7 @@ export function useHostFeeds(feeds: { socket.onmessage = (event) => { if (typeof event.data !== 'string') return try { - const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }> + const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean; waiting?: { needle: string; since: number } | null }> if (!Array.isArray(list)) return store.reduce(s => ctx.get('betterSidebar')?.isTabEnabled('terminal') === false ? s diff --git a/src/client/state.ts b/src/client/state.ts index 172415c..1a3c33b 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -118,6 +118,14 @@ export interface SidebarState { bottomSplits: SplitNode /** Free windows (tabs dragged out onto the conversation area). */ floats: FloatWindow[] + /** + * Live agent-terminal wait state (uuid → the wait the model currently + * blocks on in `terminal_wait_for`), mirrored from the host's + * agent-terminals push. Transient by design: sanitizeState never restores + * it, so a reload starts clean and the next push (sent immediately on WS + * attach) repopulates it. + */ + agentWaits: Record } export const PANEL_MIN = 280 @@ -232,6 +240,7 @@ export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed: bottomOpenedOnce: false, bottomSplits: bottomLeaf, floats: [], + agentWaits: {}, } } @@ -1002,6 +1011,23 @@ export function agentTabId(uuid: string): string { return `${AGENT_TAB_PREFIX}${uuid}` } +/** Shallow equality of two agent-wait maps (same keys, same needle+since). */ +function sameAgentWaits( + a: SidebarState['agentWaits'] | undefined, + b: Record, +): boolean { + if (a === undefined) return Object.keys(b).length === 0 + const aKeys = Object.keys(a) + if (aKeys.length !== Object.keys(b).length) return false + for (const key of aKeys) { + const av = a[key] + const bv = b[key] + if (av === undefined || bv === undefined) return false + if (av.needle !== bv.needle || av.since !== bv.since) return false + } + return true +} + /** * Reconcile the sidebar's agent-terminal tabs with the host's live list. * The host pushes the current list of agent terminals (created by the model @@ -1016,7 +1042,7 @@ export function agentTabId(uuid: string): string { */ export function reconcileAgentTerminals( state: SidebarState, - agentTerminals: ReadonlyArray<{ uuid: string; title: string }>, + agentTerminals: ReadonlyArray<{ uuid: string; title: string; waiting?: { needle: string; since: number } | null }>, ): SidebarState { const existingTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs) .concat(state.floats.map(float => float.tab)) @@ -1031,7 +1057,17 @@ export function reconcileAgentTerminals( // convergence: no title suffix, no meta write — the tab keeps its uuid // so a later reconcile push revives it if the agent reopens the same one). const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id)) && tab.pin === undefined) - if (toAdd.length === 0 && toRemove.length === 0) return state + // Mirror the live wait state from the push (authoritative: a vanished + // waiting field simply drops the entry). A waits-only change must still + // produce a new state — the tab add/remove no-change check alone would + // swallow banner updates. + const serverWaits: Record = {} + for (const terminal of agentTerminals) { + if (terminal.waiting !== undefined && terminal.waiting !== null) { + serverWaits[terminal.uuid] = { needle: terminal.waiting.needle, since: terminal.waiting.since } + } + } + if (toAdd.length === 0 && toRemove.length === 0 && sameAgentWaits(state.agentWaits, serverWaits)) return state // Remove tabs whose uuids vanished from the server list (the agent closed // them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup; // a FLOATED agent terminal leaves with its window. @@ -1057,7 +1093,7 @@ export function reconcileAgentTerminals( } next = openTabInActivePane(next, tab) } - return next + return { ...next, agentWaits: serverWaits } } // ── The per-session store ────────────────────────────────────────────────── @@ -1299,6 +1335,9 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined { bottomOpenedOnce: record.bottomOpenedOnce === true, bottomSplits, floats, + // The agent wait state is TRANSIENT (like revealed): never restored from + // storage — the host's first push after attach repopulates it. + agentWaits: {}, } } diff --git a/tests/agent-terminal-reconcile.spec.ts b/tests/agent-terminal-reconcile.spec.ts index e9fdf65..bc20e5e 100644 --- a/tests/agent-terminal-reconcile.spec.ts +++ b/tests/agent-terminal-reconcile.spec.ts @@ -126,4 +126,24 @@ describe('agent terminal reconciliation', () => { expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true) // new, added }) }) + + it('mirrors the pushed waiting state into agentWaits and clears it when it ends', () => { + let s = makeDefaultState() + s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'busy', waiting: { needle: 'READY_1', since: 42 } }]) + expect(s.agentWaits['aaa-111']).toEqual({ needle: 'READY_1', since: 42 }) + // The next push without waiting drops the entry (the push is authoritative). + s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }]) + expect(s.agentWaits['aaa-111']).toBeUndefined() + }) + + it('produces a new state when ONLY the wait state changes (no tab add/remove)', () => { + let s = makeDefaultState() + s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }]) + const before = s + const next = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable', waiting: { needle: 'N', since: 1 } }]) + expect(next).not.toBe(before) + expect(next.agentWaits['aaa-111']).toBeDefined() + // Idempotent: the same push again returns the same reference. + expect(reconcileAgentTerminals(next, [{ uuid: 'aaa-111', title: 'stable', waiting: { needle: 'N', since: 1 } }])).toBe(next) + }) })