diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts index 6c66d5cb9d3..e33256cbea7 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts @@ -8,13 +8,15 @@ export type SummaryAgentGroup = { agents: DashboardAgentRowData[] } +// Why interrupted sits below done (STA-5357): Esc / Ctrl+C is a deliberate act, so the user already +// knows — it demands the least attention of any live-ish state, matching smart-attention's Class 4. const SUMMARY_STATE_ORDER: AgentDotState[] = [ 'waiting', 'blocked', - 'interrupted', 'working', 'monitoring', 'done', + 'interrupted', 'idle' ] diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts index 43662bff074..63de1ec11a5 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -125,7 +125,9 @@ describe('resolveTerminalTabActivityStatus', () => { ).toBe('interrupted') }) - it('does not let a finished sibling mask an interrupted pane', () => { + it('lets a finished sibling win — interrupted is the quietest state', () => { + // Why: Esc / Ctrl+C is deliberate, so the user already knows. Interrupted must be + // DISTINGUISHABLE from done (that is the bug) without being LOUDER than anything live. const interrupted = entry(FIRST_LEAF_ID, 'done', { interrupted: true }) const finished = entry(SECOND_LEAF_ID, 'done') expect( @@ -137,7 +139,7 @@ describe('resolveTerminalTabActivityStatus', () => { }, ptyIdsByTabId: LIVE_PTY }) - ).toBe('interrupted') + ).toBe('done') }) it('falls back to a live working title when hook status is stale', () => { diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts index 923a26c006d..6350422fcfa 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -213,16 +213,17 @@ export function resolveTerminalTabAttentionBadge({ if (status === 'monitoring') { return 'monitoring' } - // Why above unread and done: a cancelled turn needs a human, and an unread bell would bury it. - if (status === 'interrupted') { - return 'interrupted' - } if (hasUnread) { return 'unread' } if (status === 'done') { return 'done' } + // Why last (STA-5357): the user pressed Esc / Ctrl+C, so this is the least attention-demanding + // signal — it must be distinguishable from `done`, but it must not outrank anything live. + if (status === 'interrupted') { + return 'interrupted' + } return null } diff --git a/src/renderer/src/lib/worktree-status.interrupted.test.ts b/src/renderer/src/lib/worktree-status.interrupted.test.ts index e51d68d8741..33cdd2355a1 100644 --- a/src/renderer/src/lib/worktree-status.interrupted.test.ts +++ b/src/renderer/src/lib/worktree-status.interrupted.test.ts @@ -16,23 +16,21 @@ const base = { describe('resolveWorktreeStatus — interrupted (STA-5357)', () => { it('reports interrupted rather than done for an interrupted agent', () => { - // Why `hasLiveDone` is also set: `interrupted` only ever coexists with `done` - // (agent-status-types.ts clamps it), so the done flag is always present too. - // Before the fix that made the card indistinguishable from a clean finish. - expect(resolveWorktreeStatus({ ...base, hasInterrupted: true, hasLiveDone: true })).toBe( - 'interrupted' - ) + // The bug: `interrupted` is clamped onto `done` at parse time, so the summary swallowed it into + // hasLiveDone and the card rendered a cancelled turn as a clean finish. + expect(resolveWorktreeStatus({ ...base, hasInterrupted: true })).toBe('interrupted') }) - it('keeps interrupted visible when a sibling agent merely finished', () => { - expect(resolveWorktreeStatus({ ...base, hasInterrupted: true, hasLiveDone: true })).not.toBe( - 'done' - ) + it('is never the emerald done state on its own', () => { + expect(resolveWorktreeStatus({ ...base, hasInterrupted: true })).not.toBe('done') }) - it('outranks working, so a cancelled turn is not hidden by a busy sibling', () => { + // Why interrupted YIELDS to everything else: Esc / Ctrl+C is a deliberate act, so the user already + // knows. It is the least attention-demanding state — smart-attention already classes it 4 (idle), + // below both done and working. Distinguishable from done, never louder than a live agent. + it('yields to a working sibling — the live agent is the louder signal', () => { expect(resolveWorktreeStatus({ ...base, hasInterrupted: true, hasLiveWorking: true })).toBe( - 'interrupted' + 'working' ) }) @@ -42,12 +40,16 @@ describe('resolveWorktreeStatus — interrupted (STA-5357)', () => { ) }) - it('outranks monitoring', () => { + it('yields to monitoring — background work is still live', () => { expect(resolveWorktreeStatus({ ...base, hasInterrupted: true, hasLiveMonitoring: true })).toBe( - 'interrupted' + 'monitoring' ) }) + it('yields to a sibling that genuinely finished', () => { + expect(resolveWorktreeStatus({ ...base, hasInterrupted: true, hasLiveDone: true })).toBe('done') + }) + it('leaves every other combination alone', () => { expect(resolveWorktreeStatus({ ...base, hasLiveDone: true })).toBe('done') expect(resolveWorktreeStatus({ ...base, hasLiveWorking: true })).toBe('working') diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index d9092e78950..0f52fe232a5 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -167,13 +167,6 @@ export function resolveWorktreeStatus(args: { if (heuristic === 'permission') { return 'permission' } - // Why above working (STA-5357): `interrupted` only ever rides on `done`, so before this it set - // `hasLiveDone` and the card rendered a cancelled turn as a clean finish — and a busy sibling could - // hide it entirely. It sits below permission because a prompt waiting on the user is the more urgent - // ask, and matches SUMMARY_STATE_ORDER so the card and the expanded agent list agree. - if (args.hasInterrupted) { - return 'interrupted' - } // Why: restored cards get the hook snapshot before panes mount; trust the explicit working row so they stay yellow on restart. if (args.hasLiveWorking || heuristic === 'working') { return 'working' @@ -184,5 +177,13 @@ export function resolveWorktreeStatus(args: { if (args.hasLiveDone || args.hasRetainedDone) { return 'done' } + // Why LAST (STA-5357): `interrupted` means the user pressed Esc or Ctrl+C, so they already know — + // it is the least attention-demanding state, exactly as smart-attention classes it (Class 4, idle, + // below both done and working). It still needs its own branch rather than folding into `done`, + // because the flag rides on `done` and rendering it as a completion is the bug this fixes: a + // cancelled turn is not a finished one. + if (args.hasInterrupted) { + return 'interrupted' + } return heuristic }