diff --git a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx
index 9acf590819f..d7bc4ad472c 100644
--- a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx
+++ b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx
@@ -665,7 +665,7 @@ describe('WorktreeJumpPalette recent chats & terminals', () => {
expect(getTabRowIds()).toContain('tab-alpha')
})
- it('excludes the current tab when its agent is merely done', async () => {
+ it.each([undefined, true])('excludes current terminal outcomes', async (interrupted) => {
await renderPalette(
makeRecentTabState({
activeWorktreeId: 'wt-alpha',
@@ -674,7 +674,9 @@ describe('WorktreeJumpPalette recent chats & terminals', () => {
activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' },
activeTabTypeByWorktree: { 'wt-alpha': 'terminal' },
agentStatusByPaneKey: {
- [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'done', Date.now())
+ [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'done', Date.now(), {
+ interrupted
+ })
}
})
)
diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx
index 0d69d1a9a68..25644b7ec51 100644
--- a/src/renderer/src/components/WorktreeJumpPalette.tsx
+++ b/src/renderer/src/components/WorktreeJumpPalette.tsx
@@ -449,7 +449,7 @@ function shouldIncludeOpenTabInRecentSection({
unreadAgentCompletionPanes
})
})
- return badge != null && badge !== 'done'
+ return badge != null && badge !== 'done' && badge !== 'interrupted'
}
function PaletteRowShortcutBadge({
diff --git a/src/renderer/src/components/sidebar/StatusIndicator.test.ts b/src/renderer/src/components/sidebar/StatusIndicator.test.ts
index 89fdcb12f75..c7ebd3a59ec 100644
--- a/src/renderer/src/components/sidebar/StatusIndicator.test.ts
+++ b/src/renderer/src/components/sidebar/StatusIndicator.test.ts
@@ -60,4 +60,11 @@ describe('StatusIndicator', () => {
expect(classNames).toContain('bg-emerald-500')
})
+
+ it('renders interrupted distinctly from done', () => {
+ const classNames = renderDotClassNames('interrupted')
+
+ expect(classNames).toContain('bg-red-500')
+ expect(classNames).not.toContain('bg-emerald-500')
+ })
})
diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx
index 97726487c19..26493bb7d40 100644
--- a/src/renderer/src/components/sidebar/StatusIndicator.tsx
+++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx
@@ -53,6 +53,18 @@ const StatusIndicator = React.memo(function StatusIndicator({
)
}
+ if (status === 'interrupted') {
+ return (
+
+
+
+ )
+ }
+
if (status === 'permission') {
return (
{
expect(summary).toMatchObject({ hasLiveWorking: false, hasLiveMonitoring: true })
})
+ it('separates interrupted outcomes from clean completion', () => {
+ vi.spyOn(Date, 'now').mockReturnValue(2_000)
+ const paneKey = makePaneKey('tab-1', LEAF_ID)
+ const summary = selectWorktreeAgentActivitySummary(
+ {
+ tabsByWorktree: { 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')] },
+ agentStatusEpoch: 2,
+ agentStatusByPaneKey: {
+ [paneKey]: makeAgentStatusEntry({
+ paneKey,
+ state: 'done',
+ interrupted: true
+ })
+ },
+ migrationUnsupportedByPtyId: {},
+ runtimeAgentOrchestrationByPaneKey: {},
+ retainedAgentsByPaneKey: {}
+ },
+ 'repo::/wt-1'
+ )
+
+ expect(summary).toMatchObject({ hasInterrupted: true, hasLiveDone: false })
+ })
+
it('lets an unconfirmed restored row suppress only its pane title', () => {
vi.spyOn(Date, 'now').mockReturnValue(2_000)
const paneKey = makePaneKey('tab-1', LEAF_ID)
diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts
index 638341bf8f3..fbb2d93869a 100644
--- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts
+++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts
@@ -16,6 +16,8 @@ export type WorktreeAgentActivitySummary = {
hasPermission: boolean
hasLiveWorking: boolean
hasLiveMonitoring: boolean
+ /** Fresh interrupted completion, kept separate from clean done outcomes. */
+ hasInterrupted: boolean
hasLiveDone: boolean
hasRetainedDone: boolean
agentStatusPaneIdsByTabId: Record>
@@ -27,6 +29,7 @@ const EMPTY_SUMMARY: WorktreeAgentActivitySummary = {
hasPermission: false,
hasLiveWorking: false,
hasLiveMonitoring: false,
+ hasInterrupted: false,
hasLiveDone: false,
hasRetainedDone: false,
agentStatusPaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID
@@ -180,6 +183,7 @@ function summariesEqual(
previous.hasPermission === next.hasPermission &&
previous.hasLiveWorking === next.hasLiveWorking &&
previous.hasLiveMonitoring === next.hasLiveMonitoring &&
+ previous.hasInterrupted === next.hasInterrupted &&
previous.hasLiveDone === next.hasLiveDone &&
previous.hasRetainedDone === next.hasRetainedDone &&
agentStatusPaneIdsByTabIdEqual(
@@ -217,10 +221,13 @@ function agentStatusPaneIdsByTabIdEqual(
function applyLiveAgentState(
summary: WorktreeAgentActivitySummary,
- entry: Pick
+ entry: Pick
): void {
if (entry.state === 'blocked' || entry.state === 'waiting') {
summary.hasPermission = true
+ } else if (entry.interrupted === true) {
+ // Interrupted is encoded as done, so it must be checked first.
+ summary.hasInterrupted = true
} else if (entry.state === 'working') {
if (entry.workingMode === 'monitoring') {
summary.hasLiveMonitoring = true
diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts
index bbb115c8f65..87096cf9532 100644
--- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts
@@ -55,4 +55,18 @@ describe('worktree card agent summary', () => {
/Monitoring background tasks<\/span>]*> - Run background checks<\/span>/
)
})
+
+ it('lists interrupted outcomes before clean completions', () => {
+ const done = monitoringAgent()
+ done.state = 'done'
+ done.entry.state = 'done'
+ done.entry.workingMode = undefined
+ const interrupted = {
+ ...done,
+ paneKey: 'tab-1:leaf-2',
+ entry: { ...done.entry, paneKey: 'tab-1:leaf-2', interrupted: true }
+ }
+
+ expect(summarizeAgents([done, interrupted], 'Agents')).toBe('Agents: 1 interrupted, 1 done')
+ })
})
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..8a23b708b6d 100644
--- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts
+++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts
@@ -11,9 +11,9 @@ export type SummaryAgentGroup = {
const SUMMARY_STATE_ORDER: AgentDotState[] = [
'waiting',
'blocked',
- 'interrupted',
'working',
'monitoring',
+ 'interrupted',
'done',
'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 f173b4261f3..d2bed923686 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
@@ -110,7 +110,7 @@ describe('resolveTerminalTabActivityStatus', () => {
).toBe('done')
})
- it('treats an interrupted done as done, matching the worktree card', () => {
+ it('reports an interrupted done as interrupted, matching the worktree card', () => {
const interrupted = entry(FIRST_LEAF_ID, 'done', { interrupted: true })
expect(
resolveTerminalTabActivityStatus({
@@ -118,7 +118,22 @@ describe('resolveTerminalTabActivityStatus', () => {
agentStatusByPaneKey: { [interrupted.paneKey]: interrupted },
ptyIdsByTabId: LIVE_PTY
})
- ).toBe('done')
+ ).toBe('interrupted')
+ })
+
+ it('does not let a finished sibling mask an interrupted outcome', () => {
+ const interrupted = entry(FIRST_LEAF_ID, 'done', { interrupted: true })
+ const finished = entry(SECOND_LEAF_ID, 'done')
+ expect(
+ resolveTerminalTabActivityStatus({
+ tab: TAB,
+ agentStatusByPaneKey: {
+ [interrupted.paneKey]: interrupted,
+ [finished.paneKey]: finished
+ },
+ ptyIdsByTabId: LIVE_PTY
+ })
+ ).toBe('interrupted')
})
it('falls back to a live working title when hook status is stale', () => {
@@ -273,6 +288,9 @@ describe('resolveTerminalTabAttentionBadge', () => {
)
expect(resolveTerminalTabAttentionBadge({ status: 'done', hasUnread: true })).toBe('unread')
expect(resolveTerminalTabAttentionBadge({ status: 'done', hasUnread: false })).toBe('done')
+ expect(resolveTerminalTabAttentionBadge({ status: 'interrupted', hasUnread: false })).toBe(
+ 'interrupted'
+ )
expect(resolveTerminalTabAttentionBadge({ status: 'active', hasUnread: false })).toBeNull()
})
})
@@ -302,6 +320,7 @@ describe('terminalTabActivityToAgentDotState', () => {
expect(terminalTabActivityToAgentDotState('monitoring')).toBe('monitoring')
expect(terminalTabActivityToAgentDotState('permission')).toBe('permission')
expect(terminalTabActivityToAgentDotState('done')).toBe('done')
+ expect(terminalTabActivityToAgentDotState('interrupted')).toBe('interrupted')
expect(terminalTabActivityToAgentDotState('active')).toBeNull()
expect(terminalTabActivityToAgentDotState('inactive')).toBeNull()
})
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 c130a602e1a..97f511a1d41 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
@@ -20,6 +20,7 @@ type TerminalTabActivityFlags = {
hasPermission: boolean
hasLiveWorking: boolean
hasLiveMonitoring: boolean
+ hasInterrupted: boolean
hasLiveDone: boolean
paneIds: Set
}
@@ -81,10 +82,10 @@ function getTerminalTabActivityFlags(
} else {
flags.hasLiveWorking = true
}
+ } else if (entry.interrupted === true) {
+ // Interrupted is encoded as done, so it must be checked first.
+ flags.hasInterrupted = true
} else if (entry.state === 'done') {
- // Why: an interrupted `done` still reads as completed here, matching the
- // WorktreeCard dot (resolveWorktreeStatus has no interrupted state); only
- // the smart-sort ordering treats interrupts as idle.
flags.hasLiveDone = true
}
}
@@ -103,6 +104,7 @@ function getOrCreateTerminalTabActivityFlags(
hasPermission: false,
hasLiveWorking: false,
hasLiveMonitoring: false,
+ hasInterrupted: false,
hasLiveDone: false,
paneIds: new Set()
}
@@ -164,6 +166,7 @@ export function resolveTerminalTabActivityStatus({
hasPermission: flags?.hasPermission ?? false,
hasLiveWorking: flags?.hasLiveWorking ?? false,
hasLiveMonitoring: flags?.hasLiveMonitoring ?? false,
+ hasInterrupted: flags?.hasInterrupted ?? false,
hasLiveDone: flags?.hasLiveDone ?? false,
// Why: retained/orchestration promotions are worktree-aggregate concerns;
// a tab reflects its own live panes and title only.
@@ -180,7 +183,13 @@ export function isTerminalTabActivityLive(status: TerminalTabActivityStatus): bo
* Glyph-bearing attention states for a terminal tab (tab bar + Cmd+J recent chats).
* Quiet active/inactive map to null so identity icons stay clean.
*/
-export type TerminalTabAttentionBadge = 'working' | 'monitoring' | 'permission' | 'unread' | 'done'
+export type TerminalTabAttentionBadge =
+ | 'working'
+ | 'monitoring'
+ | 'permission'
+ | 'interrupted'
+ | 'unread'
+ | 'done'
/**
* Single priority ladder shared by the tab strip and Cmd+J recent rows:
@@ -208,17 +217,21 @@ export function resolveTerminalTabAttentionBadge({
if (status === 'done') {
return 'done'
}
+ if (status === 'interrupted') {
+ return 'interrupted'
+ }
return null
}
/** Map a container activity status onto AgentStateDot's vocabulary (no unread — that's a bell). */
export function terminalTabActivityToAgentDotState(
status: TerminalTabActivityStatus
-): 'working' | 'monitoring' | 'permission' | 'done' | null {
+): 'working' | 'monitoring' | 'permission' | 'interrupted' | 'done' | null {
switch (status) {
case 'working':
case 'monitoring':
case 'permission':
+ case 'interrupted':
case 'done':
return status
case 'active':
diff --git a/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts b/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts
index 19c97ada254..dad45ebc01d 100644
--- a/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts
+++ b/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts
@@ -93,7 +93,8 @@ export const LEAF_ID = '11111111-2222-4333-8444-555555555555'
export function makeAgentEntry(
tabId: string,
state: AgentStatusState,
- stateStartedAt: number
+ stateStartedAt: number,
+ overrides: Partial = {}
): AgentStatusEntry {
return {
state,
@@ -101,7 +102,8 @@ export function makeAgentEntry(
updatedAt: stateStartedAt,
stateStartedAt,
paneKey: makePaneKey(tabId, LEAF_ID),
- stateHistory: []
+ stateHistory: [],
+ ...overrides
}
}
diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts
index 1a8336f323c..54b56a72bb3 100644
--- a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts
+++ b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts
@@ -312,6 +312,27 @@ describe('orderRecentWorkspaceTabs', () => {
})
describe('resolveRecentWorkspaceTabStatus', () => {
+ it('surfaces an interrupted outcome without promoting its sort class', () => {
+ const interrupted = entry('interrupted', 'done', NOW - 1_000, { interrupted: true })
+
+ expect(resolveRecentWorkspaceTabStatus(row('interrupted'), sources([interrupted]), NOW)).toBe(
+ 'interrupted'
+ )
+ })
+
+ it('does not let a cleanly finished sibling mask an interruption', () => {
+ const interrupted = entry('mixed', 'done', NOW - 1_000, {
+ paneKey: `mixed:${LEAF_ID}`,
+ interrupted: true
+ })
+ const finished = entry('mixed', 'done', NOW - 2_000, {
+ paneKey: 'mixed:22222222-2222-4222-8222-222222222222'
+ })
+
+ expect(
+ resolveRecentWorkspaceTabStatus(row('mixed'), sources([interrupted, finished]), NOW)
+ ).toBe('interrupted')
+ })
it('maps attention classes onto the sidebar dot vocabulary', () => {
const blocked = row('blocked')
const done = row('done')
diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.ts b/src/renderer/src/lib/recent-workspace-tab-rows.ts
index 9167db9b318..f0c66a5eb6c 100644
--- a/src/renderer/src/lib/recent-workspace-tab-rows.ts
+++ b/src/renderer/src/lib/recent-workspace-tab-rows.ts
@@ -7,10 +7,12 @@ import {
type WorktreeAttention
} from '@/components/sidebar/smart-attention'
import { tabHasLivePty } from './tab-has-live-pty'
+import { isExplicitAgentStatusFresh } from './pane-agent-evidence'
import type { WorktreeStatus } from './worktree-status'
import type { TabGroup } from '../../../shared/tab-types'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import type { ExecutionHostId } from '../../../shared/execution-host'
+import { AGENT_STATUS_STALE_AFTER_MS } from '../../../shared/agent-status-types'
import { getWorktreeVisitTimestamp } from './worktree-visit-recency'
import { composeWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity'
@@ -105,7 +107,19 @@ export function resolveRecentWorkspaceTabStatus(
)
return hasForegroundWork ? 'working' : 'monitoring'
}
- if (explicit) {
+ if (explicit === 'permission') {
+ return explicit
+ }
+ const hasInterrupted = panes.some(
+ (pane) =>
+ pane.kind === 'hook' &&
+ pane.entry.interrupted === true &&
+ isExplicitAgentStatusFresh(pane.entry, now, AGENT_STATUS_STALE_AFTER_MS)
+ )
+ if (hasInterrupted) {
+ return 'interrupted'
+ }
+ if (explicit === 'done') {
return explicit
}
return tabHasLivePty(paneSources.ptyIdsByTabId, row.terminalTab.id) ? 'active' : 'inactive'
diff --git a/src/renderer/src/lib/worktree-status.interrupted.test.ts b/src/renderer/src/lib/worktree-status.interrupted.test.ts
new file mode 100644
index 00000000000..76536ff8c7a
--- /dev/null
+++ b/src/renderer/src/lib/worktree-status.interrupted.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest'
+import { resolveWorktreeStatus } from './worktree-status'
+
+const base = {
+ tabs: [] as never[],
+ browserTabs: [] as never[],
+ ptyIdsByTabId: {},
+ hasPermission: false,
+ hasLiveWorking: false,
+ hasLiveDone: false,
+ hasRetainedDone: false
+}
+
+describe('resolveWorktreeStatus — interrupted (STA-5357)', () => {
+ it('reports interrupted rather than done for an interrupted agent', () => {
+ expect(resolveWorktreeStatus({ ...base, hasInterrupted: true })).toBe('interrupted')
+ })
+
+ it('is never the emerald done state on its own', () => {
+ expect(resolveWorktreeStatus({ ...base, hasInterrupted: true })).not.toBe('done')
+ })
+
+ it('yields to a working sibling — the live agent is the louder signal', () => {
+ expect(resolveWorktreeStatus({ ...base, hasInterrupted: true, hasLiveWorking: true })).toBe(
+ 'working'
+ )
+ })
+
+ it('yields to permission — a prompt waiting on the user is more urgent', () => {
+ expect(resolveWorktreeStatus({ ...base, hasInterrupted: true, hasPermission: true })).toBe(
+ 'permission'
+ )
+ })
+
+ it('yields to monitoring — background work is still live', () => {
+ expect(resolveWorktreeStatus({ ...base, hasInterrupted: true, hasLiveMonitoring: true })).toBe(
+ 'monitoring'
+ )
+ })
+
+ it('outranks a cleanly finished sibling', () => {
+ expect(resolveWorktreeStatus({ ...base, hasInterrupted: true, hasLiveDone: true })).toBe(
+ 'interrupted'
+ )
+ })
+
+ it('leaves every other combination alone', () => {
+ expect(resolveWorktreeStatus({ ...base, hasLiveDone: true })).toBe('done')
+ expect(resolveWorktreeStatus({ ...base, hasLiveWorking: true })).toBe('working')
+ expect(resolveWorktreeStatus({ ...base, hasLiveMonitoring: true })).toBe('monitoring')
+ expect(resolveWorktreeStatus({ ...base, hasPermission: true })).toBe('permission')
+ })
+})
diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts
index 038e9f8ef11..4e234436958 100644
--- a/src/renderer/src/lib/worktree-status.ts
+++ b/src/renderer/src/lib/worktree-status.ts
@@ -16,6 +16,7 @@ export type WorktreeStatus =
| 'working'
| 'monitoring'
| 'permission'
+ | 'interrupted'
| 'done'
| 'inactive'
@@ -31,6 +32,7 @@ const STATUS_LABELS: Record = {
working: 'Working',
monitoring: 'Monitoring background tasks',
permission: 'Needs permission',
+ interrupted: 'Interrupted',
done: 'Done',
inactive: 'Inactive'
}
@@ -124,8 +126,7 @@ export function getWorktreeStatusLabel(status: WorktreeStatus): string {
}
/**
- * Apply the WorktreeCard priority overlay (permission > working > done >
- * heuristic) on top of the title-heuristic base. Explicit agent rows may
+ * Apply the WorktreeCard priority overlay on top of the title-heuristic base. Explicit agent rows may
* promote the dot; sleep cleanup owns removing stale retained rows.
*
* Map args are narrowed to this worktree. `hasPermission`/`hasLiveWorking`/
@@ -143,6 +144,7 @@ export function resolveWorktreeStatus(args: {
hasPermission: boolean
hasLiveWorking: boolean
hasLiveMonitoring?: boolean
+ hasInterrupted?: boolean
hasLiveDone: boolean
hasRetainedDone: boolean
}): WorktreeStatus {
@@ -171,6 +173,10 @@ export function resolveWorktreeStatus(args: {
if (args.hasLiveMonitoring || heuristic === 'monitoring') {
return 'monitoring'
}
+ // Terminal outcomes follow live states, but an interrupted outcome must not collapse into success.
+ if (args.hasInterrupted) {
+ return 'interrupted'
+ }
if (args.hasLiveDone || args.hasRetainedDone) {
return 'done'
}