mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
fix(sidebar): stop reporting an interrupted agent as done (STA-5357) (#16312)
* fix(sidebar): stop reporting an interrupted agent as done (STA-5357)
An interrupted turn rendered on the worktree card and the tab glyph as `done` —
visually identical to a clean completion. A cancelled or dead turn read as
finished work, and with several agents it contributed no signal at all, so a
sibling that merely finished could hide it entirely.
`interrupted` is a flag clamped onto `done` at parse time, never its own state,
so the activity summary never even received it: its Pick was
{state, workingMode}, and the `done` branch swallowed it into hasLiveDone.
Adds the flag end to end — summary, both activity hooks, the resolver, the card
glyph and the tab badge — and slots it below permission and above working, which
is where SUMMARY_STATE_ORDER already ranked it for the text summary. The card and
the expanded agent list now agree.
The tab-bar test that asserted the old behavior is flipped rather than deleted:
it explicitly pinned `done` to mirror the card, and that premise is gone.
* 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.
* fix(status): preserve interrupted outcomes in aggregates
This commit is contained in:
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@@ -449,7 +449,7 @@ function shouldIncludeOpenTabInRecentSection({
|
||||
unreadAgentCompletionPanes
|
||||
})
|
||||
})
|
||||
return badge != null && badge !== 'done'
|
||||
return badge != null && badge !== 'done' && badge !== 'interrupted'
|
||||
}
|
||||
|
||||
function PaletteRowShortcutBadge({
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,6 +53,18 @@ const StatusIndicator = React.memo(function StatusIndicator({
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'interrupted') {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}
|
||||
title={resolvedTitle}
|
||||
{...rest}
|
||||
>
|
||||
<span className="block size-1.5 rounded-full bg-red-500" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'permission') {
|
||||
return (
|
||||
<span
|
||||
|
||||
@@ -26,6 +26,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
|
||||
hasPermission,
|
||||
hasLiveWorking,
|
||||
hasLiveMonitoring,
|
||||
hasInterrupted,
|
||||
hasLiveDone,
|
||||
hasRetainedDone,
|
||||
agentStatusPaneIdsByTabId
|
||||
@@ -46,6 +47,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
|
||||
hasPermission,
|
||||
hasLiveWorking,
|
||||
hasLiveMonitoring,
|
||||
hasInterrupted,
|
||||
hasLiveDone,
|
||||
hasRetainedDone
|
||||
}),
|
||||
@@ -59,6 +61,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
|
||||
hasPermission,
|
||||
hasLiveWorking,
|
||||
hasLiveMonitoring,
|
||||
hasInterrupted,
|
||||
hasLiveDone,
|
||||
hasRetainedDone
|
||||
]
|
||||
|
||||
@@ -34,6 +34,7 @@ export function selectWorktreeActivityStatuses(
|
||||
hasPermission,
|
||||
hasLiveWorking,
|
||||
hasLiveMonitoring,
|
||||
hasInterrupted,
|
||||
hasLiveDone,
|
||||
hasRetainedDone,
|
||||
agentStatusPaneIdsByTabId
|
||||
@@ -50,6 +51,7 @@ export function selectWorktreeActivityStatuses(
|
||||
hasPermission,
|
||||
hasLiveWorking,
|
||||
hasLiveMonitoring,
|
||||
hasInterrupted,
|
||||
hasLiveDone,
|
||||
hasRetainedDone
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ function makeAgentStatusEntry(args: {
|
||||
parentPaneKey?: string
|
||||
restoredUnconfirmed?: true
|
||||
workingMode?: AgentStatusEntry['workingMode']
|
||||
interrupted?: true
|
||||
}): AgentStatusEntry {
|
||||
return {
|
||||
paneKey: args.paneKey,
|
||||
@@ -28,6 +29,7 @@ function makeAgentStatusEntry(args: {
|
||||
worktreeId: args.worktreeId,
|
||||
restoredUnconfirmed: args.restoredUnconfirmed,
|
||||
workingMode: args.workingMode,
|
||||
interrupted: args.interrupted,
|
||||
orchestration: args.parentPaneKey
|
||||
? {
|
||||
taskId: 'task-1',
|
||||
@@ -193,6 +195,30 @@ describe('selectWorktreeAgentActivitySummary', () => {
|
||||
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)
|
||||
|
||||
@@ -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<string, ReadonlySet<string>>
|
||||
@@ -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<AgentStatusEntry, 'state' | 'workingMode'>
|
||||
entry: Pick<AgentStatusEntry, 'state' | 'workingMode' | 'interrupted'>
|
||||
): 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
|
||||
|
||||
@@ -55,4 +55,18 @@ describe('worktree card agent summary', () => {
|
||||
/Monitoring background tasks<\/span><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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,9 +11,9 @@ export type SummaryAgentGroup = {
|
||||
const SUMMARY_STATE_ORDER: AgentDotState[] = [
|
||||
'waiting',
|
||||
'blocked',
|
||||
'interrupted',
|
||||
'working',
|
||||
'monitoring',
|
||||
'interrupted',
|
||||
'done',
|
||||
'idle'
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ type TerminalTabActivityFlags = {
|
||||
hasPermission: boolean
|
||||
hasLiveWorking: boolean
|
||||
hasLiveMonitoring: boolean
|
||||
hasInterrupted: boolean
|
||||
hasLiveDone: boolean
|
||||
paneIds: Set<string>
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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> = {}
|
||||
): AgentStatusEntry {
|
||||
return {
|
||||
state,
|
||||
@@ -101,7 +102,8 @@ export function makeAgentEntry(
|
||||
updatedAt: stateStartedAt,
|
||||
stateStartedAt,
|
||||
paneKey: makePaneKey(tabId, LEAF_ID),
|
||||
stateHistory: []
|
||||
stateHistory: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@ export type WorktreeStatus =
|
||||
| 'working'
|
||||
| 'monitoring'
|
||||
| 'permission'
|
||||
| 'interrupted'
|
||||
| 'done'
|
||||
| 'inactive'
|
||||
|
||||
@@ -31,6 +32,7 @@ const STATUS_LABELS: Record<WorktreeStatus, string> = {
|
||||
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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user