From 01e66ba122e03ccee6b2c8abad3616c80c423fa9 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 2 Nov 2026 13:29:05 +0700 Subject: [PATCH] fix(chat): Stop shows while Claude thinks, not only while its footer says "esc to interrupt" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat's live line said "Thinking… 21s" while the box offered Send, so the turn could not be stopped. Stop shows only when a hook marked main "working" or the pane's busy line matched, and Claude Code 2.1.3xx writes "esc to interrupt" only in its footer, which a narrow pane, a tip or a notice replaces. Claude's busy line now also matches its spinner (verb… (41s · …)), which the line a finished turn leaves ("Cooked for 1m 2s") does not; and the chat re-reads "working" on transcript_changed, so a hook that reached another dashboard on the same database no longer leaves Stop hidden. Co-Authored-By: Claude Opus 5.5 --- client/src/components/run/PaneChat.tsx | 5 ++++- client/src/lib/__tests__/panePaste.test.ts | 8 ++++++++ client/src/lib/panePaste.ts | 11 ++++++++--- 3 files changed, 20 insertions(+), 4 deletions(-) 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, };