From 7cb162819aa3a7c25dd6f42c72ea1adfa5f9d515 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:08:05 -0700 Subject: [PATCH] fix(sidebar): spin the worktree dot for Claude's braille-spinner titles (#9040) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worktree activity dot required a provider token in the terminal title before it would attribute activity to an agent. Claude's thinking title is a braille spinner plus free-form task text ("⠋ implementing the feature") with no provider token, so the gate rejected it and the worktree resolved to 'active' the whole time Claude was working. StatusIndicator renders 'active' and 'done' with the same emerald dot and only 'working' as the yellow spinner ring, so the symptom reads as a missing thinking indicator. The sidebar row builder already solved this in #9647 by falling back to the tab's launchAgent for spinner-only titles. Thread the same launch identity into the dot's attribution gate so the dot and the row agree. The #9647 invariant is preserved: a spinner with no launch identity still cannot spin the dot, so a frozen spinner frame left behind by an exited agent does not produce a permanent "working, 0 agents" state. --- ...rktree-status-spinner-launch-agent.test.ts | 55 +++++++++++++++++++ src/renderer/src/lib/worktree-status.ts | 30 +++++++--- 2 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts diff --git a/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts b/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts new file mode 100644 index 00000000000..ffaeea41a28 --- /dev/null +++ b/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { getWorktreeStatus } from './worktree-status' + +function livePtyMap(...tabIds: string[]): Record { + return Object.fromEntries(tabIds.map((id, i) => [id, [`pty-${i}`]])) +} + +// Why: #9040 — Claude's thinking title is a braille spinner plus task text with no +// provider token, so the dot's attribution gate rejected it and the worktree stayed +// grey while Claude was clearly working. The sidebar row builder already falls back +// to the tab's launch identity for spinner-only titles (#9647); the dot must agree. +describe('#9040 worktree dot attributes spinner titles to the launched agent', () => { + it('spins for a Claude spinner title when the tab was launched as claude', () => { + const status = getWorktreeStatus( + [{ id: 'tab-1', title: '⠋ implementing the feature', launchAgent: 'claude' }], + [], + livePtyMap('tab-1') + ) + + expect(status).toBe('working') + }) + + it('spins for a spinner-only pane title when the tab was launched as claude', () => { + const status = getWorktreeStatus( + [{ id: 'tab-1', title: 'bash', launchAgent: 'claude' }], + [], + livePtyMap('tab-1'), + { 'tab-1': { 0: '⠙ refactoring the parser' } } + ) + + expect(status).toBe('working') + }) + + // Why: pins the #9647 gate — spinner attribution needs a launch identity, so a + // frozen spinner frame left by an exited agent still cannot spin the dot forever. + it('stays active for a spinner title with no launch identity', () => { + const status = getWorktreeStatus( + [{ id: 'tab-1', title: '⠐ Review branch for regressions' }], + [], + livePtyMap('tab-1') + ) + + expect(status).toBe('active') + }) + + it('does not manufacture activity from a non-spinner title with a launch identity', () => { + const status = getWorktreeStatus( + [{ id: 'tab-1', title: 'bash', launchAgent: 'claude' }], + [], + livePtyMap('tab-1') + ) + + expect(status).toBe('active') + }) +}) diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index 902e3cb2257..e12d197dda9 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -2,10 +2,12 @@ import { resolveAgentTypeFromTerminalTitle } from '@/components/sidebar/worktree import { classifyTitleActivity } from '@/lib/pane-agent-evidence' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { resolveRuntimePaneTitleLeafIdFromRoot } from '@/lib/runtime-pane-title-leaf-id' +import { containsBrailleSpinner } from '../../../shared/agent-title-core' import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode, - TerminalTab + TerminalTab, + TuiAgent } from '../../../shared/types' import type { LiveAgentWorktreeStatus } from './worktree-activity-state' @@ -27,7 +29,7 @@ const STATUS_LABELS: Record = { } export function getWorktreeStatus( - tabs: readonly Pick[], + tabs: readonly Pick[], browserTabs: readonly { id: string }[], ptyIdsByTabId: Record, runtimePaneTitlesByTabId: Record> = {}, @@ -54,7 +56,7 @@ export function getWorktreeStatus( } function tabHasStatus( - tab: Pick, + tab: Pick, runtimePaneTitlesByTabId: Record>, status: 'permission' | 'working', options: WorktreeStatusHeuristicOptions @@ -77,7 +79,10 @@ function tabHasStatus( ) { continue } - if (classifyTitleActivity(title) === status && titleStatusIsAgentAttributable(title)) { + if ( + classifyTitleActivity(title) === status && + titleStatusIsAgentAttributable(title, tab.launchAgent) + ) { return true } } @@ -87,12 +92,21 @@ function tabHasStatus( if (agentStatusPaneIds && agentStatusPaneIds.size > 0) { return false } - return classifyTitleActivity(tab.title) === status && titleStatusIsAgentAttributable(tab.title) + return ( + classifyTitleActivity(tab.title) === status && + titleStatusIsAgentAttributable(tab.title, tab.launchAgent) + ) } // Why: require agent attribution so a bare never-cleared spinner title can't spin the dot "0 agents" forever with no matching sidebar row. -function titleStatusIsAgentAttributable(title: string): boolean { - return resolveAgentTypeFromTerminalTitle(title) !== null +function titleStatusIsAgentAttributable(title: string, launchAgent?: TuiAgent | null): boolean { + if (resolveAgentTypeFromTerminalTitle(title) !== null) { + return true + } + // Why: a spinner proves activity but not identity (Claude's thinking title has no provider + // token, #9040); the tab's launch identity supplies it, mirroring the row builder's spinner + // fallback (#9647) so the dot and the sidebar row agree. + return containsBrailleSpinner(title) && Boolean(launchAgent) } export function getWorktreeStatusLabel(status: WorktreeStatus): string { @@ -109,7 +123,7 @@ export function getWorktreeStatusLabel(status: WorktreeStatus): string { * `hasRetainedDone` is a retained-agent snapshot scoped to this worktreeId. */ export function resolveWorktreeStatus(args: { - tabs: readonly Pick[] + tabs: readonly Pick[] browserTabs: readonly { id: string }[] ptyIdsByTabId: Record runtimePaneTitlesByTabId?: Record>