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' }