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>