diff --git a/client/src/components/run/PaneChat.tsx b/client/src/components/run/PaneChat.tsx index 066fa304..047d4696 100644 --- a/client/src/components/run/PaneChat.tsx +++ b/client/src/components/run/PaneChat.tsx @@ -689,8 +689,11 @@ export const PaneChat = memo(function PaneChat({ // another dashboard on the same database, or the socket dropped) would // otherwise leave Stop up after the turn ended. intervalMs: working || waiting ? 5000 : undefined, + // The transcript growing re-reads it too: a hook that reached another + // dashboard on the same database sends this one no agent_updated, and + // without a poll Stop never showed for a turn the chat calls Thinking…. match: (msg) => - msg.type === "agent_updated" && + (msg.type === "agent_updated" || msg.type === "transcript_changed") && (msg.data as { session_id?: string } | undefined)?.session_id === sessionId, }); diff --git a/client/src/lib/__tests__/panePaste.test.ts b/client/src/lib/__tests__/panePaste.test.ts index 0858ed7c..add2312a 100644 --- a/client/src/lib/__tests__/panePaste.test.ts +++ b/client/src/lib/__tests__/panePaste.test.ts @@ -432,6 +432,14 @@ describe("readRunBusy", () => { let rows = ["✻ Thinking… (4s · ↓ 120 tokens · esc to interrupt)"]; const off = registerPane("agenthub-lane-8", () => {}, () => true, undefined, undefined, () => rows); expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true); + // Claude's spinner with the footer's hint gone (a narrow pane, a tip). + rows = ["✶ Skedaddling… (41s · ↓ 2.8k tokens · thought for 2s)", " ⏵⏵ auto mode on"]; + expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true); + rows = ["✻ Thinking… (1m 3s)"]; + expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true); + // The line a finished turn leaves is not. + rows = ["✻ Cooked for 1m 2s", "❯ "]; + expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false); // Claude's permission footer is not a turn at work. rows = ["Esc to cancel · Tab to amend"]; expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false); diff --git a/client/src/lib/panePaste.ts b/client/src/lib/panePaste.ts index 8fc0aeda..3bbf15d0 100644 --- a/client/src/lib/panePaste.ts +++ b/client/src/lib/panePaste.ts @@ -368,10 +368,15 @@ function typeRow(options: PaneMenuOption[]): number { /** The status line a tool's TUI shows only while a turn runs, read off its * binary's own strings: Claude's and Codex's `esc to interrupt`, agy's * `Thinking… (3s · esc to cancel)` — in parentheses after an ellipsis, so - * Claude's permission footer (`Esc to cancel · Tab to amend`) is not it. A - * tool missing here (omp, Cursor) has no line we know. */ + * Claude's permission footer (`Esc to cancel · Tab to amend`) is not it. + * Claude ≥ 2.1.3xx keeps `esc to interrupt` only in its footer, which a + * narrow pane, a tip or a notice replaces, so its spinner line counts too: + * a verb, an ellipsis and the turn's time in parentheses + * (`✶ Skedaddling… (41s · ↓ 2.8k tokens)`) — the line a finished turn leaves + * (`✻ Cooked for 1m 2s`) has neither. A tool missing here (omp, Cursor) has + * no line we know. */ export const BUSY_LINE: Readonly> = { - claude: /\besc to interrupt\b/i, + claude: /\besc to interrupt\b|…\s*\(\s*\d+[smh]\b/i, codex: /\besc to interrupt\b/i, antigravity: /…\s*\([^)]*\besc to cancel\)/i, };