From 00ea0fa69f0590adc74bc335a9c643f4d1143132 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:08:41 -0700 Subject: [PATCH] fix(agent-status): show waiting for Claude AskUserQuestion, stop stale-title worktree spinners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude's AskUserQuestion tool is auto-allowed, so it emits a PreToolUse (not PermissionRequest) while blocked on a human answer. normalizeClaudeEvent mapped that to `working`, so the sidebar showed a spinner that decayed to grey while the question sat. Map the auto-allowed AskUserQuestion PreToolUse to `waiting` instead, mirroring the Kimi/OpenCode handling, so the row and worktree dots read amber "Waiting for input". Separately, a frozen braille-spinner title left by an exited agent (e.g. "⠐ Review branch for regressions" over a shell prompt) kept classifyTitleActivity returning `working`, spinning the worktree dot forever with 0 agents — the row builder rejects the same unattributable title, so no agent row is shown. Gate tabHasStatus's title-derived working/permission on the same agent attribution the row builder uses (resolveAgentTypeFromTerminalTitle), so a title only spins the dot when it would also show a row. Hook-driven status (hasLiveWorking/ hasPermission) is untouched. --- src/renderer/src/lib/worktree-status.test.ts | 63 ++++++++++++++++++++ src/renderer/src/lib/worktree-status.ts | 15 ++++- src/shared/agent-hook-listener.test.ts | 61 +++++++++++++++++++ src/shared/agent-hook-listener.ts | 14 ++++- 4 files changed, 148 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/lib/worktree-status.test.ts b/src/renderer/src/lib/worktree-status.test.ts index 85176224614..e3b88ee082d 100644 --- a/src/renderer/src/lib/worktree-status.test.ts +++ b/src/renderer/src/lib/worktree-status.test.ts @@ -93,6 +93,39 @@ describe('getWorktreeStatus', () => { expect(status).toBe('active') }) + + // Why: an exited agent can leave a frozen braille-spinner frame in the + // title forever (reproduced live: '⠐ Review branch for regressions' over a + // plain shell prompt). The row builder rejects that title as a Claude row + // (no explicit "claude" token), so the sidebar shows 0 agents — the worktree + // dot must agree instead of spinning on the unattributable title alone. + it('does not spin on a bare braille-spinner tab title that yields no agent row', () => { + const status = getWorktreeStatus( + [{ id: 'tab-1', title: '⠐ Review branch for regressions' }], + [], + livePtyMap('tab-1') + ) + + expect(status).toBe('active') + }) + + it('does not spin on a bare braille-spinner pane title that yields no agent row', () => { + const status = getWorktreeStatus([{ id: 'tab-1', title: 'bash' }], [], livePtyMap('tab-1'), { + 'tab-1': { 0: '⠐ Review branch for regressions', 1: 'bash' } + }) + + expect(status).toBe('active') + }) + + it('still spins on an agent-attributable braille-spinner title', () => { + const status = getWorktreeStatus( + [{ id: 'tab-1', title: '⠹ codex fix flaky test' }], + [], + livePtyMap('tab-1') + ) + + expect(status).toBe('working') + }) }) describe('resolveWorktreeStatus', () => { @@ -330,6 +363,36 @@ describe('resolveWorktreeStatus', () => { expect(status).toBe('done') }) + it('keeps a hook-backed working agent spinning despite an unattributable stale title', () => { + // Why: hook-managed agents drive the dot via hasLiveWorking, not the title + // heuristic — the attribution gate must not regress them. + const status = resolveWorktreeStatus({ + tabs: [{ id: 'tab-1', title: '⠐ Review branch for regressions' }], + browserTabs: [], + ptyIdsByTabId: livePtyMap('tab-1'), + hasPermission: false, + hasLiveWorking: true, + hasLiveDone: false, + hasRetainedDone: false + }) + + expect(status).toBe('working') + }) + + it('resolves a stale unattributable spinner title with no hook entry to active, not working', () => { + const status = resolveWorktreeStatus({ + tabs: [{ id: 'tab-1', title: '⠐ Review branch for regressions' }], + browserTabs: [], + ptyIdsByTabId: livePtyMap('tab-1'), + hasPermission: false, + hasLiveWorking: false, + hasLiveDone: false, + hasRetainedDone: false + }) + + expect(status).toBe('active') + }) + it('falls through to active heuristic when nothing else applies', () => { const status = resolveWorktreeStatus({ tabs: [{ id: 'tab-1', title: 'bash' }], diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index 0c3c87bf062..c7509767cba 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -1,3 +1,4 @@ +import { resolveAgentTypeFromTerminalTitle } from '@/components/sidebar/worktree-title-derived-agent-rows' import { classifyTitleActivity } from '@/lib/pane-agent-evidence' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { resolveRuntimePaneTitleLeafIdFromRoot } from '@/lib/runtime-pane-title-leaf-id' @@ -91,7 +92,7 @@ function tabHasStatus( ) { continue } - if (classifyTitleActivity(title) === status) { + if (classifyTitleActivity(title) === status && titleStatusIsAgentAttributable(title)) { return true } } @@ -103,7 +104,17 @@ function tabHasStatus( if (agentStatusPaneIds && agentStatusPaneIds.size > 0) { return false } - return classifyTitleActivity(tab.title) === status + return classifyTitleActivity(tab.title) === status && titleStatusIsAgentAttributable(tab.title) +} + +// Why: the title heuristic is only a fallback — hook-managed agents promote the +// worktree via hasLiveWorking/hasPermission in resolveWorktreeStatus, not here. +// A bare braille-spinner title (e.g. an exited agent's never-cleared "⠐ task" +// frame) classifies as working yet produces no sidebar agent row, spinning the +// worktree forever with "0 agents". Require the same agent attribution the row +// builder uses, so a title only spins the dot when it would also show a row. +function titleStatusIsAgentAttributable(title: string): boolean { + return resolveAgentTypeFromTerminalTitle(title) !== null } export function getWorktreeStatusLabel(status: WorktreeStatus): string { diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index cedc0efda32..3d4baeac3c4 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -170,6 +170,9 @@ describe('shared agent-hook-listener', () => { ) expect(event?.payload.toolName).toBe('AskUserQuestion') + // Why: the auto-allowed AskUserQuestion PreToolUse is a human-input boundary, + // so it must read as waiting (amber attention) rather than a working spinner. + expect(event?.payload.state).toBe('waiting') const expected = JSON.stringify(questions) expect(event?.payload.interactivePrompt).toBe(expected) // Why: must NOT be truncated to the 160-char toolInput preview cap. @@ -177,6 +180,64 @@ describe('shared agent-hook-listener', () => { expect(event?.payload.interactivePrompt!.length).toBe(expected.length) }) + it('maps Claude AskUserQuestion PreToolUse to waiting, then back to working on answer', () => { + const question = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'AskUserQuestion', + tool_input: { questions: [{ question: 'Pick', options: ['a', 'b'] }] } + } + }, + 'production' + ) + const answered = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PostToolUse', + tool_name: 'AskUserQuestion', + tool_response: { selected: ['a'] } + } + }, + 'production' + ) + + expect(question?.payload).toMatchObject({ + agentType: 'claude', + state: 'waiting', + toolName: 'AskUserQuestion' + }) + expect(answered?.payload).toMatchObject({ + agentType: 'claude', + state: 'working', + toolName: 'AskUserQuestion' + }) + }) + + it('keeps a normal Claude PreToolUse tool call as working', () => { + const event = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'ls' } + } + }, + 'production' + ) + expect(event?.payload.state).toBe('working') + expect(event?.payload.toolName).toBe('Bash') + }) + it('leaves interactivePrompt undefined for a normal tool call', () => { const event = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index e73d2ae9dc9..1619e4bd7ec 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -2162,13 +2162,21 @@ function normalizeClaudeEvent( paneKey: string, hookPayload: Record ): ParsedAgentStatusPayload | null { + // Why: Claude's AskUserQuestion tool is auto-allowed, so it emits PreToolUse + // (not PermissionRequest) while blocked on a human answer — Claude posts a + // Notification instead of PermissionRequest, and Orca does not register the + // Notification hook. Treat that PreToolUse as waiting so the sidebar shows the + // amber attention state instead of a working spinner that decays to grey while + // the question sits unanswered. Mirrors normalizeKimiEvent's handling. + const isAskUserQuestion = + eventName === 'PreToolUse' && isAskUserQuestionTool(readString(hookPayload, 'tool_name')) const stateName = eventName === 'UserPromptSubmit' || - eventName === 'PreToolUse' || eventName === 'PostToolUse' || - eventName === 'PostToolUseFailure' + eventName === 'PostToolUseFailure' || + (eventName === 'PreToolUse' && !isAskUserQuestion) ? 'working' - : eventName === 'PermissionRequest' + : eventName === 'PermissionRequest' || isAskUserQuestion ? 'waiting' : eventName === 'Stop' || eventName === 'StopFailure' ? 'done'