fix(sidebar): rank interrupted below every live state

Corrects the precedence from the previous commit. Interrupted means the user
pressed Esc or Ctrl+C — a deliberate act, so they already know. It must be
DISTINGUISHABLE from done (that is the bug) without being LOUDER than anything
live.

Moved below done in all three ladders — resolveWorktreeStatus, the tab attention
badge, and SUMMARY_STATE_ORDER, which had also ranked it above working. That
matches smart-attention, which already classes an interrupted done as 4 (idle),
below both done and working; the card, the tab, the summary text and the sort now
agree instead of two of them disagreeing.

Tests re-pinned to the corrected order, including one of my own from the previous
commit that asserted a finished sibling must not mask an interrupted pane — it
should, and now does.
This commit is contained in:
Brennan Benson
2026-08-24 15:39:29 -07:00
parent a601e8407a
commit e5e6b60a6e
5 changed files with 36 additions and 28 deletions
@@ -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'
]
@@ -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', () => {
@@ -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
}
@@ -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')
+8 -7
View File
@@ -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
}