feat(agents): distinguish Claude background monitoring

Adds an optional `workingMode: 'monitoring'` discriminator for a Claude
session whose lead turn finished but which still has background shell tasks
or session crons registered. The wire state stays `working`, so older peers
that never read the field keep rendering Working.

(cherry picked from commit d5d54b4bdd)

Rebased onto current main (554 commits of drift) by Brennan Benson;
conflicts resolved by keeping both sides where main and this branch made
independent additions to the same construct.
This commit is contained in:
Neil
2026-08-23 18:52:11 -07:00
committed by Brennan Benson
parent 4bc9e6b00c
commit 151cb5ee3b
87 changed files with 10580 additions and 222 deletions
+20 -3
View File
@@ -1,5 +1,7 @@
import { useEffect, useRef } from 'react'
import { Radio } from 'lucide-react-native'
import { Animated, Easing, StyleSheet, View } from 'react-native'
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
@@ -17,11 +19,18 @@ const STATUS_COLORS: Record<WorktreeStatus, string> = {
inactive: 'rgba(115,115,115,0.4)'
}
export function AgentSpinner({ status }: { status: WorktreeStatus }) {
export function AgentSpinner({
status,
workingMode
}: {
status: WorktreeStatus
workingMode?: AgentWorkingMode
}) {
const spinValue = useRef(new Animated.Value(0)).current
const monitoring = status === 'working' && workingMode === 'monitoring'
useEffect(() => {
if (status === 'working') {
if (status === 'working' && !monitoring) {
const animation = Animated.loop(
Animated.timing(spinValue, {
toValue: 1,
@@ -34,10 +43,18 @@ export function AgentSpinner({ status }: { status: WorktreeStatus }) {
return () => animation.stop()
}
spinValue.setValue(0)
}, [status, spinValue])
}, [monitoring, status, spinValue])
const color = STATUS_COLORS[status] ?? STATUS_COLORS.inactive
if (monitoring) {
return (
<View style={styles.wrapper} accessibilityLabel="Monitoring background tasks">
<Radio size={12} color={STATUS_COLORS.working} />
</View>
)
}
if (status === 'working') {
const rotate = spinValue.interpolate({
inputRange: [0, 1],
+12 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
import { Radio } from 'lucide-react-native'
import { Animated, Easing, StyleSheet, View } from 'react-native'
import type { AgentDotState } from '../worktree/agent-row-display'
@@ -7,13 +8,14 @@ import type { AgentDotState } from '../worktree/agent-row-display'
// emerald for 'done', red for blocked/waiting/interrupted (attention), neutral
// for idle. Distinct from the worktree-level AgentSpinner, which collapses the
// agent vocabulary into the 5-state rollup the sidebar dot uses.
const DOT_COLORS: Record<Exclude<AgentDotState, 'working'>, string> = {
const DOT_COLORS: Record<Exclude<AgentDotState, 'working' | 'monitoring'>, string> = {
done: '#10b981',
blocked: '#ef4444',
waiting: '#ef4444',
interrupted: '#ef4444',
idle: 'rgba(115,115,115,0.4)'
}
const WORKING_COLOR = '#eab308'
export function AgentStateDot({ state }: { state: AgentDotState }) {
const spinValue = useRef(new Animated.Value(0)).current
@@ -44,6 +46,14 @@ export function AgentStateDot({ state }: { state: AgentDotState }) {
)
}
if (state === 'monitoring') {
return (
<View style={styles.wrapper} accessibilityLabel="Monitoring background tasks">
<Radio size={10} color={WORKING_COLOR} />
</View>
)
}
return (
<View style={styles.wrapper}>
<View style={[styles.dot, { backgroundColor: DOT_COLORS[state] }]} />
@@ -59,7 +69,7 @@ const styles = StyleSheet.create({
height: 6,
borderRadius: 3,
borderWidth: 1.5,
borderColor: '#eab308',
borderColor: WORKING_COLOR,
borderTopColor: 'transparent'
}
})
+18 -1
View File
@@ -199,7 +199,7 @@ describe('memoized worktree rows', () => {
await act(async () => {
renderer!.update(
createElement(WorktreeAgentRow, {
agent: agent({ state: 'done', updatedAt: 2_000 }),
agent: agent({ state: 'working', workingMode: 'monitoring', updatedAt: 2_000 }),
depth: 0,
now: 2_000,
unvisited: false
@@ -207,5 +207,22 @@ describe('memoized worktree rows', () => {
)
})
expect(agentStateDotRender).toHaveBeenCalledTimes(2)
expect(agentStateDotRender).toHaveBeenLastCalledWith({ state: 'monitoring' })
})
it('passes workspace monitoring mode to the status indicator', async () => {
await act(async () => {
renderer = create(
createElement(ListRowHarness, {
item: { ...baseItem, status: 'working', workingMode: 'monitoring' },
now: 2_000
})
)
})
expect(agentSpinnerRender).toHaveBeenLastCalledWith({
status: 'working',
workingMode: 'monitoring'
})
})
})
+3 -1
View File
@@ -2,6 +2,7 @@ import { memo } from 'react'
import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import type { RepoIcon } from '../../../src/shared/repo-icon'
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { triggerMediumImpact } from '../platform/haptics'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
@@ -39,6 +40,7 @@ export type WorktreeListRowItem = {
lineageChildCount?: number
lineageCollapsed?: boolean
agents?: RuntimeWorktreeAgentRow[]
workingMode?: AgentWorkingMode
}
type WorktreeRollupStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
@@ -97,7 +99,7 @@ function WorktreeListRowComponent<T extends WorktreeListRowItem>({
delayLongPress={400}
>
<View style={styles.indicatorCol}>
<AgentSpinner status={status} />
<AgentSpinner status={status} workingMode={item.workingMode} />
{item.unread && (
<Bell
size={10}
@@ -0,0 +1,80 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentSpinner } from './AgentSpinner'
import { AgentStateDot } from './AgentStateDot'
const DESKTOP_WORKING_COLOR = '#eab308'
const { animationLoop, animationTiming, setValue } = vi.hoisted(() => ({
animationLoop: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
animationTiming: vi.fn(() => ({})),
setValue: vi.fn()
}))
vi.mock('lucide-react-native', () => ({ Radio: 'Radio' }))
vi.mock('react-native', () => ({
Animated: {
Value: function Value() {
return { interpolate: vi.fn(() => 'rotation'), setValue }
},
View: 'AnimatedView',
loop: animationLoop,
timing: animationTiming
},
Easing: { linear: 'linear' },
StyleSheet: { create: <T>(styles: T) => styles },
View: 'View'
}))
describe('mobile monitoring indicators', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
animationLoop.mockClear()
animationTiming.mockClear()
setValue.mockClear()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('renders a static Radio for a monitoring agent', async () => {
await act(async () => {
renderer = create(createElement(AgentStateDot, { state: 'monitoring' }))
})
expect(renderer.root.findByType('Radio').props).toMatchObject({
color: DESKTOP_WORKING_COLOR,
size: 10
})
expect(animationTiming).not.toHaveBeenCalled()
expect(animationLoop).not.toHaveBeenCalled()
})
it('renders a static Radio for an all-monitoring workspace', async () => {
await act(async () => {
renderer = create(
createElement(AgentSpinner, { status: 'working', workingMode: 'monitoring' })
)
})
expect(renderer.root.findByType('Radio').props).toMatchObject({
color: DESKTOP_WORKING_COLOR,
size: 12
})
expect(animationTiming).not.toHaveBeenCalled()
expect(animationLoop).not.toHaveBeenCalled()
})
it('keeps the spinner fallback when workingMode is absent', async () => {
await act(async () => {
renderer = create(createElement(AgentSpinner, { status: 'working' }))
})
expect(animationTiming).toHaveBeenCalledOnce()
expect(animationLoop).toHaveBeenCalledOnce()
})
})
@@ -697,7 +697,9 @@ describe('useMobileNativeChatController streaming scope', () => {
launchAgent: 'claude',
agentStatus: {
state: 'working',
workingMode: undefined as 'monitoring' | undefined,
agentType: 'claude',
lastAssistantMessage: 'Partial reply',
providerSession: { id: 'session-1' }
},
isActive: true
@@ -723,6 +725,7 @@ describe('useMobileNativeChatController streaming scope', () => {
beforeEach(() => {
viewMode.isTabChatView = () => true
workingTab.agentStatus.workingMode = undefined
act(() => {
renderer = create(createElement(Harness))
})
@@ -737,6 +740,7 @@ describe('useMobileNativeChatController streaming scope', () => {
it('holds the stream scope and liveness while the user peeks at the terminal', () => {
expect(controller?.showNativeChat).toBe(true)
expect(controller?.nativeChatAgentWorking).toBe(true)
const scopeKey = controller?.nativeChatStreamScopeKey
expect(scopeKey).toContain('session-1')
expect(controller?.nativeChatStreamLive).toBe(true)
@@ -750,6 +754,16 @@ describe('useMobileNativeChatController streaming scope', () => {
expect(controller?.nativeChatStreamLive).toBe(true)
})
it('treats passive monitoring as outside the foreground turn', () => {
workingTab.agentStatus.workingMode = 'monitoring'
act(() => renderer?.update(createElement(Harness)))
expect(controller?.nativeChatAgentWorking).toBe(false)
expect(controller?.nativeChatStreamLive).toBe(false)
expect(controller?.nativeChatStreamingText).toBeUndefined()
expect(controller?.nativeChatSessionOptions?.isWorking).toBe(false)
})
it('keeps the delayed-send route guard view-gated, unlike the stream scope', () => {
const before = answerSendArgs.at(-1)?.streamIdentity
expect(before).toBe(controller?.nativeChatStreamScopeKey)
@@ -115,11 +115,14 @@ export function useMobileNativeChatController(args: {
transcriptSettled: nativeChatSession.status === 'ready'
})
const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null
const nativeChatAgentWorking = nativeChatStatus?.state === 'working'
const activeTabStatus = activeSessionTab?.agentStatus
const activeTabAgentWorking =
activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring'
const nativeChatStatus = activeChatResolution ? activeTabStatus : null
const nativeChatAgentWorking = activeChatResolution != null && activeTabAgentWorking
// Deliberately not gated on the chat view being visible: the streaming gate
// has to tell "hidden mid-turn" from "the turn ended".
const nativeChatStreamLive = activeSessionTab?.agentStatus?.state === 'working'
const nativeChatStreamLive = activeTabAgentWorking
// Throttle the streaming bubble: OpenCode emits a status frame per streamed
// part, and each one re-renders and re-parses the whole accumulated markdown.
const nativeChatStreamingText = useThrottledLatestValue(
@@ -28,6 +28,9 @@ function row(overrides: Partial<RuntimeWorktreeAgentRow> = {}): RuntimeWorktreeA
describe('agentDotState', () => {
it('maps known states through and unknown to idle', () => {
expect(agentDotState(row({ state: 'working', updatedAt: 0 }), 0)).toBe('working')
expect(
agentDotState(row({ state: 'working', workingMode: 'monitoring', updatedAt: 0 }), 0)
).toBe('monitoring')
expect(agentDotState(row({ state: 'blocked', updatedAt: 0 }), 0)).toBe('blocked')
expect(agentDotState(row({ state: 'waiting', updatedAt: 0 }), 0)).toBe('waiting')
expect(agentDotState(row({ state: 'done', updatedAt: 0 }), 0)).toBe('done')
@@ -65,6 +68,12 @@ describe('agentDisplayLabel', () => {
expect(agentDisplayLabel(row({ state: 'working', prompt: '', updatedAt: 0 }), 0)).toBe(
'Working'
)
expect(
agentDisplayLabel(
row({ state: 'working', workingMode: 'monitoring', prompt: '', updatedAt: 0 }),
0
)
).toBe('Monitoring background tasks')
})
it('falls back to the decayed state label when stale', () => {
+16 -3
View File
@@ -9,23 +9,34 @@ export const AGENT_STATUS_STALE_AFTER_MS = 30 * 60 * 1000
// Mirrors the desktop AgentStateDot vocabulary. The wire `state` is the agent
// status state; 'blocked'/'waiting' read as attention states, 'done' as
// complete, everything else idle.
export type AgentDotState = 'working' | 'blocked' | 'waiting' | 'done' | 'idle' | 'interrupted'
export type AgentDotState =
| 'working'
| 'monitoring'
| 'blocked'
| 'waiting'
| 'done'
| 'idle'
| 'interrupted'
export function agentDotState(
row: Pick<RuntimeWorktreeAgentRow, 'state' | 'interrupted' | 'updatedAt'>,
row: Pick<RuntimeWorktreeAgentRow, 'state' | 'workingMode' | 'interrupted' | 'updatedAt'>,
now: number
): AgentDotState {
if (row.interrupted) {
return 'interrupted'
}
switch (row.state) {
case 'working':
case 'blocked':
case 'waiting':
// Why: an agent that exits without a final report would otherwise read as
// active forever. Decay a stale active state to idle, matching desktop's
// renderer-side staleness decay (worktree-agent-rows.ts).
return now - row.updatedAt > AGENT_STATUS_STALE_AFTER_MS ? 'idle' : row.state
case 'working':
if (now - row.updatedAt > AGENT_STATUS_STALE_AFTER_MS) {
return 'idle'
}
return row.workingMode === 'monitoring' ? 'monitoring' : 'working'
case 'done':
return 'done'
}
@@ -37,6 +48,8 @@ export function agentStateLabel(state: AgentDotState): string {
switch (state) {
case 'working':
return 'Working'
case 'monitoring':
return 'Monitoring background tasks'
case 'blocked':
return 'Blocked'
case 'waiting':
@@ -1,4 +1,5 @@
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
export type Worktree = {
@@ -45,6 +46,7 @@ export type Worktree = {
linkedGitLabIssue?: number | null
comment?: string
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
workingMode?: AgentWorkingMode
agents?: RuntimeWorktreeAgentRow[]
}
@@ -160,6 +160,20 @@ describe('areWorktreeListsEqual', () => {
expect(areWorktreeListsEqual(first, second)).toBe(false)
})
it('detects monitoring mode changes within working', () => {
const first = [worktree({ agents: [agent({ state: 'working' })] })]
const second = [worktree({ agents: [agent({ state: 'working', workingMode: 'monitoring' })] })]
expect(areWorktreeListsEqual(first, second)).toBe(false)
})
it('detects workspace monitoring mode changes within working', () => {
const first = [worktree({ status: 'working' })]
const second = [worktree({ status: 'working', workingMode: 'monitoring' })]
expect(areWorktreeListsEqual(first, second)).toBe(false)
})
it('treats missing and empty agent arrays as equivalent for rendering', () => {
const first = [worktree({ agents: undefined })]
const second = [worktree({ agents: [] })]
@@ -55,6 +55,7 @@ function areWorktreesEqual(left: Worktree, right: Worktree): boolean {
(left.linkedGitLabIssue ?? null) === (right.linkedGitLabIssue ?? null) &&
(left.comment ?? '') === (right.comment ?? '') &&
(left.status ?? null) === (right.status ?? null) &&
(left.workingMode ?? null) === (right.workingMode ?? null) &&
arePullRequestsEqual(left.linkedPR, right.linkedPR) &&
areAgentRowsEqual(left.agents ?? [], right.agents ?? [])
)
@@ -102,6 +103,7 @@ function areAgentRowsEqual(
a.paneKey !== b.paneKey ||
a.parentPaneKey !== b.parentPaneKey ||
a.state !== b.state ||
a.workingMode !== b.workingMode ||
a.agentType !== b.agentType ||
a.prompt !== b.prompt ||
a.lastAssistantMessage !== b.lastAssistantMessage ||
@@ -38,6 +38,7 @@ describe('createHookStatusSessionTabsInvalidator', () => {
it.each([
['state', { state: 'waiting' as const }],
['workingMode', { workingMode: 'monitoring' as const }],
['prompt', { prompt: 'ship it' }],
['agentType', { agentType: 'codex' }],
['toolName', { toolName: 'Bash' }],
@@ -33,6 +33,7 @@ export function createHookStatusSessionTabsInvalidator(): {
return (
!previous ||
previous.payload.state !== next.state ||
previous.payload.workingMode !== next.workingMode ||
previous.payload.prompt !== next.prompt ||
(previous.payload.agentType ?? null) !== (next.agentType ?? null) ||
(previous.payload.toolName ?? null) !== (next.toolName ?? null) ||
File diff suppressed because it is too large Load Diff
+9 -26
View File
@@ -72,6 +72,7 @@ import {
type ParsedAgentStatusPayload,
normalizeAgentStatusPayload
} from '../../shared/agent-status-types'
import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence'
import {
AgentStatusObservationSequencer,
createAgentStatusAuthorityId,
@@ -448,30 +449,6 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta
}
}
// Why: OSC never carries model/children; omit both so an equivalent OSC ping preserves the hook-cached identity graph.
function equivalentParsedAgentStatusPayload(
a: ParsedAgentStatusPayload,
b: ParsedAgentStatusPayload,
preserveActiveTurnStamp = false
): boolean {
return (
a.state === b.state &&
a.prompt === b.prompt &&
a.agentType === b.agentType &&
a.toolName === b.toolName &&
a.toolInput === b.toolInput &&
a.interactivePrompt === b.interactivePrompt &&
(a.lastAssistantMessage === b.lastAssistantMessage ||
(preserveActiveTurnStamp && b.lastAssistantMessage === undefined)) &&
a.interrupted === b.interrupted &&
// Why: a session-boundary done must never be deduped against a cached real done —
// the flag has to reach receivers deterministically (STA-3386).
a.sessionBoundary === b.sessionBoundary &&
(a.turnCompletedAt === b.turnCompletedAt ||
(preserveActiveTurnStamp && b.turnCompletedAt === undefined))
)
}
function trackEmptyPaneKeyHook(body: unknown): void {
if (typeof body !== 'object' || body === null) {
return
@@ -1039,6 +1016,7 @@ export class AgentHookServer {
providerSession: existing.providerSession,
payload: {
state: restored.state,
...(restored.workingMode ? { workingMode: restored.workingMode } : {}),
prompt: payload.prompt,
agentType: payload.agentType,
...(restored.state === 'done' && restored.interrupted ? { interrupted: true } : {}),
@@ -2201,7 +2179,7 @@ export class AgentHookServer {
previous?.connectionId === connectionId &&
previous.tabId === tabId &&
previous.worktreeId === worktreeId &&
equivalentParsedAgentStatusPayload(previous.payload, event.payload, preserveActiveTurnStamp)
terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp)
) {
return
}
@@ -2949,7 +2927,12 @@ export class AgentHookServer {
...(state !== 'done' && restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
receivedAt: reconciledAt,
stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt,
payload: { ...enriched.payload, state, subagents }
payload: {
...enriched.payload,
state,
workingMode: state === 'working' ? enriched.payload.workingMode : undefined,
subagents
}
}
this.state.lastStatusByPaneKey.set(paneKey, reconciled)
}
@@ -99,6 +99,19 @@ function imageIconSrc(bodyBytes: number, withWhitespace = false): string {
describe('dashboard payload validation', () => {
it('accepts a complete dashboard snapshot', () => {
expect(isDashboardSnapshot(SNAPSHOT)).toBe(true)
expect(
isDashboardSnapshot({
...SNAPSHOT,
cards: [
{
...SNAPSHOT.cards[0],
bucket: 'working',
dotState: 'working',
workingMode: 'monitoring'
}
]
})
).toBe(true)
})
it('rejects malformed or unbounded snapshot fields', () => {
@@ -109,6 +122,18 @@ describe('dashboard payload validation', () => {
cards: [{ ...SNAPSHOT.cards[0], bucket: 'unexpected' }]
})
).toBe(false)
expect(
isDashboardSnapshot({
...SNAPSHOT,
cards: [{ ...SNAPSHOT.cards[0], dotState: 'monitoring' }]
})
).toBe(false)
expect(
isDashboardSnapshot({
...SNAPSHOT,
cards: [{ ...SNAPSHOT.cards[0], dotState: 'done', workingMode: 'monitoring' }]
})
).toBe(false)
expect(
isDashboardSnapshot({
...SNAPSHOT,
@@ -257,6 +257,8 @@ function isDashboardCard(value: unknown): boolean {
DASHBOARD_BUCKETS.has(card.bucket) &&
typeof card.dotState === 'string' &&
DASHBOARD_DOT_STATES.has(card.dotState) &&
(card.workingMode === undefined ||
(card.dotState === 'working' && card.workingMode === 'monitoring')) &&
isBoundedString(card.task, AGENT_STATUS_MAX_FIELD_LENGTH, true) &&
isOptionalBoundedString(card.lastUserMessage, AGENT_STATUS_MAX_FIELD_LENGTH) &&
isOptionalBoundedString(card.lastAgentMessage, AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH) &&
+255
View File
@@ -37711,6 +37711,261 @@ describe('OrcaRuntimeService', () => {
expect(summary).toMatchObject({ hasHostSidebarActivity: true, status: 'working' })
})
it('projects monitoring for folder workspaces without assuming a git worktree', async () => {
const now = Date.now()
const folderWorkspace = makeFolderWorkspace({ name: 'GG' })
const projectGroup = makeFolderProjectGroup({ name: 'Store' })
const runtime = new OrcaRuntimeService(
createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never,
undefined,
{
getAgentStatusSnapshot: () => [
{
paneKey: 'folder-pane',
worktreeId: TEST_FOLDER_WORKSPACE_KEY,
state: 'working',
workingMode: 'monitoring',
prompt: 'watch tests',
agentType: 'claude',
connectionId: null,
receivedAt: now,
stateStartedAt: now - 100
}
]
}
)
const { worktrees } = await runtime.getWorktreePs()
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_FOLDER_WORKSPACE_KEY)
expect(summary).toMatchObject({
workspaceKind: 'folder-workspace',
status: 'working',
workingMode: 'monitoring'
})
})
it('projects monitoring over a title-derived working status', async () => {
const now = Date.now()
const runtime = new OrcaRuntimeService(store, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey: 'tab-1:1',
worktreeId: TEST_WORKTREE_ID,
tabId: 'tab-1',
state: 'working',
workingMode: 'monitoring',
prompt: 'watch tests',
agentType: 'claude',
connectionId: null,
receivedAt: now,
stateStartedAt: now - 100
}
]
})
syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude working' })
const { worktrees } = await runtime.getWorktreePs()
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
expect(summary).toMatchObject({ status: 'working', workingMode: 'monitoring' })
})
it('keeps hook monitoring mode when a newer mode-less OSC row reports the same work', async () => {
const now = Date.now()
const runtime = new OrcaRuntimeService(store, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey: 'tab-1:1',
worktreeId: TEST_WORKTREE_ID,
tabId: 'tab-1',
state: 'working',
workingMode: 'monitoring',
prompt: 'watch tests',
agentType: 'claude',
connectionId: null,
receivedAt: now - 100,
stateStartedAt: now - 200
}
]
})
syncSinglePty(runtime)
runtime.onPtyData(
'pty-1',
'\x1b]9999;{"state":"working","prompt":"watch tests","agentType":"claude"}\x07',
1
)
const { worktrees } = await runtime.getWorktreePs()
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
expect(summary).toMatchObject({ status: 'working', workingMode: 'monitoring' })
expect(summary?.agents).toEqual([
expect.objectContaining({
state: 'working',
workingMode: 'monitoring',
prompt: 'watch tests'
})
])
})
it('does not carry hook monitoring mode into a newer OSC turn', async () => {
const now = Date.now()
const runtime = new OrcaRuntimeService(store, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey: 'tab-1:1',
worktreeId: TEST_WORKTREE_ID,
tabId: 'tab-1',
state: 'working',
workingMode: 'monitoring',
prompt: 'watch tests',
agentType: 'claude',
connectionId: null,
receivedAt: now - 100,
stateStartedAt: now - 200
}
]
})
syncSinglePty(runtime)
runtime.onPtyData(
'pty-1',
'\x1b]9999;{"state":"working","prompt":"fix tests","agentType":"claude"}\x07',
1
)
const { worktrees } = await runtime.getWorktreePs()
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
expect(summary).toMatchObject({ status: 'working' })
expect(summary).not.toHaveProperty('workingMode')
expect(summary?.agents).toEqual([
expect.objectContaining({ state: 'working', prompt: 'fix tests' })
])
})
it('keeps title-only foreground work ahead of monitoring in another pane', async () => {
const now = Date.now()
const monitoringLeafId = '33333333-3333-4333-8333-333333333333'
const foregroundLeafId = '44444444-4444-4444-8444-444444444444'
const runtime = new OrcaRuntimeService(store, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey: `monitoring-tab:${monitoringLeafId}`,
worktreeId: TEST_WORKTREE_ID,
tabId: 'monitoring-tab',
state: 'working',
workingMode: 'monitoring',
prompt: 'watch tests',
agentType: 'claude',
connectionId: null,
receivedAt: now,
stateStartedAt: now - 100
}
]
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'monitoring-tab',
worktreeId: TEST_WORKTREE_ID,
title: 'Claude',
activeLeafId: monitoringLeafId,
layout: null
},
{
tabId: 'foreground-tab',
worktreeId: TEST_WORKTREE_ID,
title: 'Claude',
activeLeafId: foregroundLeafId,
layout: null
}
],
leaves: [
{
tabId: 'monitoring-tab',
worktreeId: TEST_WORKTREE_ID,
leafId: monitoringLeafId,
paneRuntimeId: 1,
ptyId: 'monitoring-pty',
paneTitle: 'claude'
},
{
tabId: 'foreground-tab',
worktreeId: TEST_WORKTREE_ID,
leafId: foregroundLeafId,
paneRuntimeId: 2,
ptyId: 'foreground-pty',
paneTitle: 'claude working'
}
]
})
const { worktrees } = await runtime.getWorktreePs()
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
expect(summary).toMatchObject({ status: 'working' })
expect(summary).not.toHaveProperty('workingMode')
expect(summary?.agents).toEqual([
expect.objectContaining({ paneKey: `monitoring-tab:${monitoringLeafId}` })
])
})
it('keeps title-only foreground work ahead of monitoring in another split pane', async () => {
const now = Date.now()
const tabId = 'split-tab'
const monitoringLeafId = '33333333-3333-4333-8333-333333333333'
const foregroundLeafId = '44444444-4444-4444-8444-444444444444'
const runtime = new OrcaRuntimeService(store, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey: `${tabId}:${monitoringLeafId}`,
worktreeId: TEST_WORKTREE_ID,
tabId,
state: 'working',
workingMode: 'monitoring',
prompt: 'watch tests',
agentType: 'claude',
connectionId: null,
receivedAt: now,
stateStartedAt: now - 100
}
]
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId,
worktreeId: TEST_WORKTREE_ID,
title: 'Claude',
activeLeafId: monitoringLeafId,
layout: null
}
],
leaves: [
{
tabId,
worktreeId: TEST_WORKTREE_ID,
leafId: monitoringLeafId,
paneRuntimeId: 1,
ptyId: 'monitoring-pty',
paneTitle: 'claude'
},
{
tabId,
worktreeId: TEST_WORKTREE_ID,
leafId: foregroundLeafId,
paneRuntimeId: 2,
ptyId: 'foreground-pty',
paneTitle: 'claude working'
}
]
})
const { worktrees } = await runtime.getWorktreePs()
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
expect(summary).toMatchObject({ status: 'working' })
expect(summary).not.toHaveProperty('workingMode')
})
it('suppresses restored-unconfirmed hook rows from worktree.ps', async () => {
const leafId = '33333333-3333-4333-8333-333333333333'
const now = Date.now()
+153 -38
View File
@@ -55,6 +55,7 @@ import {
type AgentStatusOrchestrationContext,
type AgentStatusEntry
} from '../../shared/agent-status-types'
import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence'
import { indexAgentStatusRowsByPaneKey } from '../agent-hooks/agent-status-pane-index'
import type { AgentHookAuthorityAttestation } from '../agent-hooks/server'
import type {
@@ -1752,6 +1753,32 @@ type RuntimeAgentRowSnapshot = {
updatedAt: number
}
type RuntimeWorkingTerminalEvidence = {
paneKey: string | null
ptyId: string | null
tabId: string | null
}
type RuntimeWorktreeAgentSource = {
paneKey: string
ptyId?: string
tabId?: string
worktreeId?: string
connectionId: string | null
payload: ParsedAgentStatusPayload
state: ParsedAgentStatusPayload['state']
workingMode?: ParsedAgentStatusPayload['workingMode']
agentType: string | null
prompt: string
lastAssistantMessage: string | null
toolName: string | null
toolInput: string | null
interrupted: boolean
stateStartedAt: number
updatedAt: number
restoredUnconfirmed?: boolean
}
/** A hook row narrowed to what `session.tabs` publishes, shaped like the retained OSC
* snapshot so one projection branch can consume either carrier. */
type HookLiveAgentRow = Pick<
@@ -12155,6 +12182,7 @@ export class OrcaRuntimeService {
return (
!previous ||
previous.payload.state !== payload.state ||
previous.payload.workingMode !== payload.workingMode ||
previous.payload.prompt !== payload.prompt ||
(previous.payload.agentType ?? null) !== (payload.agentType ?? null) ||
(previous.payload.toolName ?? null) !== (payload.toolName ?? null) ||
@@ -19986,6 +20014,7 @@ export class OrcaRuntimeService {
const repoById = new Map((this.store?.getRepos() ?? []).map((repo) => [repo.id, repo]))
const platformByRepoId = resolvedWorktreeSnapshot.platformByRepoId
const summaries = new Map<string, RuntimeWorktreePsSummary>()
const workingTerminalEvidenceByWorktreeId = new Map<string, RuntimeWorkingTerminalEvidence[]>()
// Why: the GitHub cache is keyed by `repoPath::branch` (no refs/heads/ prefix),
// matching how the renderer's fetchPRForBranch stores entries. We look up cached
@@ -20169,10 +20198,15 @@ export class OrcaRuntimeService {
summary.liveTerminalCount += 1
summary.hasAttachedPty = true
summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, leaf.lastOutputAt)
summary.status = mergeWorktreeStatus(
summary.status,
getLeafWorktreeStatus(leaf, this.tabs.get(leaf.tabId)?.title ?? null)
)
const leafStatus = getLeafWorktreeStatus(leaf, this.tabs.get(leaf.tabId)?.title ?? null)
if (leafStatus === 'working') {
addRuntimeWorkingTerminalEvidence(workingTerminalEvidenceByWorktreeId, summary.worktreeId, {
paneKey: this.makeRuntimePaneKey(leaf),
ptyId: leaf.ptyId,
tabId: leaf.tabId
})
}
mergeWorktreeSummaryStatus(summary, leafStatus)
if (
leaf.preview &&
(summary.preview.length === 0 || (leaf.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1))
@@ -20234,10 +20268,15 @@ export class OrcaRuntimeService {
summary.hasAttachedPty = true
summary.hasHostSidebarActivity = true
summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, pty.lastOutputAt)
summary.status = mergeWorktreeStatus(
summary.status,
getSavedTabWorktreeStatus(owner.title, true)
)
const ptyStatus = getSavedTabWorktreeStatus(owner.title, true)
if (ptyStatus === 'working') {
addRuntimeWorkingTerminalEvidence(workingTerminalEvidenceByWorktreeId, summary.worktreeId, {
paneKey: pty.paneKey,
ptyId: pty.ptyId,
tabId: pty.tabId ?? persistedTabId ?? null
})
}
mergeWorktreeSummaryStatus(summary, ptyStatus)
if (
pty.preview &&
(summary.preview.length === 0 || (pty.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1))
@@ -20338,7 +20377,8 @@ export class OrcaRuntimeService {
runtimeWorktreeSummaryPathIndex,
missingRuntimeWorktreeIds,
mirroredWorktreeIdByTabId,
connectedPtyEvidence
connectedPtyEvidence,
workingTerminalEvidenceByWorktreeId
)
const sorted = [...summaries.values()].sort(compareWorktreePs)
@@ -20362,30 +20402,17 @@ export class OrcaRuntimeService {
tabIds: ReadonlySet<string>
paneKeys: ReadonlySet<string>
ptyIds: ReadonlySet<string>
}
},
workingTerminalEvidenceByWorktreeId: ReadonlyMap<
string,
readonly RuntimeWorkingTerminalEvidence[]
>
): void {
// Why: most agents report via hooks (agent-hooks/server), not OSC, so the
// hook snapshot is the primary source — same one the desktop sidebar reads.
// OSC-only entries (no hook) are merged in as a fallback, keyed by paneKey.
const rowSources = new Map<
string,
{
paneKey: string
ptyId?: string
tabId?: string
worktreeId?: string
connectionId: string | null
state: ParsedAgentStatusPayload['state']
agentType: string | null
prompt: string
lastAssistantMessage: string | null
toolName: string | null
toolInput: string | null
interrupted: boolean
stateStartedAt: number
updatedAt: number
}
>()
const rowSources = new Map<string, RuntimeWorktreeAgentSource>()
const now = Date.now()
for (const snapshot of this.latestAgentStatusByPaneKey.values()) {
const { payload } = snapshot
rowSources.set(snapshot.paneKey, {
@@ -20394,7 +20421,9 @@ export class OrcaRuntimeService {
tabId: snapshot.tabId,
worktreeId: snapshot.worktreeId,
connectionId: snapshot.connectionId,
payload,
state: payload.state,
...(payload.workingMode ? { workingMode: payload.workingMode } : {}),
agentType: payload.agentType ?? null,
prompt: payload.prompt,
lastAssistantMessage: payload.lastAssistantMessage ?? null,
@@ -20411,9 +20440,22 @@ export class OrcaRuntimeService {
continue
}
const existing = rowSources.get(entry.paneKey)
const hookPayload = pickParsedAgentStatusPayload(entry)
// Why: hook rows win ties, but an older cached hook must not replace a
// fresh OSC status and make a running mobile workspace look inactive.
if (existing && existing.updatedAt > entry.receivedAt) {
if (
entry.workingMode === 'monitoring' &&
// restoredUnconfirmed rows already `continue` above.
now - entry.receivedAt <= AGENT_STATUS_STALE_AFTER_MS &&
terminalStatusPayloadMatchesHook(hookPayload, existing.payload)
) {
// Why: older OSC reporters cannot express hook-authoritative monitoring mode.
existing.workingMode = 'monitoring'
if (existing.payload.workingMode === undefined) {
existing.payload = { ...existing.payload, workingMode: 'monitoring' }
}
}
continue
}
rowSources.set(entry.paneKey, {
@@ -20424,7 +20466,9 @@ export class OrcaRuntimeService {
tabId: entry.tabId,
worktreeId: entry.worktreeId,
connectionId: entry.connectionId,
payload: hookPayload,
state: entry.state,
...(entry.workingMode ? { workingMode: entry.workingMode } : {}),
agentType: entry.agentType ?? null,
prompt: entry.prompt,
lastAssistantMessage: entry.lastAssistantMessage ?? null,
@@ -20440,7 +20484,6 @@ export class OrcaRuntimeService {
}
const orchestrationByPaneKey = this.buildAgentOrchestrationByPaneKey()
const rowsByWorktree = new Map<string, RuntimeWorktreeAgentRow[]>()
const now = Date.now()
for (const src of rowSources.values()) {
// Why: hooks retain launch-time attribution across automatic workspace
// renames; the tab's current mirrored owner is authoritative when present.
@@ -20487,6 +20530,7 @@ export class OrcaRuntimeService {
paneKey: src.paneKey,
parentPaneKey: orchestrationByPaneKey?.[src.paneKey]?.parentPaneKey ?? null,
state: src.state,
...(src.workingMode ? { workingMode: src.workingMode } : {}),
agentType: src.agentType,
prompt: src.prompt,
taskTitle,
@@ -20513,6 +20557,8 @@ export class OrcaRuntimeService {
const summary = summaries.get(worktreeId)
if (summary) {
summary.agents = rows
let hasForegroundWorkingAgent = false
const monitoringSources: RuntimeWorktreeAgentSource[] = []
for (const row of rows) {
if (!isFreshNonDoneAgentStatus(row, now)) {
continue
@@ -20520,9 +20566,31 @@ export class OrcaRuntimeService {
// Why: worktree.ps is mobile's host-sidebar parity source, so a live
// agent must survive the same temporary PTY gaps as desktop.
summary.hasHostSidebarActivity = true
summary.status = mergeWorktreeStatus(
summary.status,
row.state === 'working' ? 'working' : 'permission'
if (row.state === 'working') {
if (row.workingMode === 'monitoring') {
const source = rowSources.get(row.paneKey)
if (source) {
monitoringSources.push(source)
}
} else {
hasForegroundWorkingAgent = true
}
} else {
mergeWorktreeSummaryStatus(summary, 'permission')
}
}
if (hasForegroundWorkingAgent || monitoringSources.length > 0) {
const hasIndependentWorkingTerminal = (
workingTerminalEvidenceByWorktreeId.get(worktreeId) ?? []
).some((evidence) =>
monitoringSources.every(
(source) => !runtimeWorkingTerminalEvidenceMatchesSource(evidence, source)
)
)
mergeWorktreeSummaryStatus(
summary,
'working',
hasForegroundWorkingAgent || hasIndependentWorkingTerminal ? undefined : 'monitoring'
)
}
}
@@ -40945,11 +41013,58 @@ function mapExplicitAgentStateToRuntimeTerminalStatus(
}
}
function mergeWorktreeStatus(
current: RuntimeWorktreeStatus,
next: RuntimeWorktreeStatus
): RuntimeWorktreeStatus {
return WORKTREE_STATUS_PRIORITY[next] > WORKTREE_STATUS_PRIORITY[current] ? next : current
function addRuntimeWorkingTerminalEvidence(
evidenceByWorktreeId: Map<string, RuntimeWorkingTerminalEvidence[]>,
worktreeId: string,
evidence: RuntimeWorkingTerminalEvidence
): void {
const existing = evidenceByWorktreeId.get(worktreeId)
if (existing) {
existing.push(evidence)
} else {
evidenceByWorktreeId.set(worktreeId, [evidence])
}
}
function runtimeWorkingTerminalEvidenceMatchesSource(
evidence: RuntimeWorkingTerminalEvidence,
source: RuntimeWorktreeAgentSource
): boolean {
if (evidence.paneKey) {
return (
evidence.paneKey === source.paneKey ||
Boolean(evidence.ptyId && source.ptyId && evidence.ptyId === source.ptyId)
)
}
if (evidence.ptyId && source.ptyId) {
return evidence.ptyId === source.ptyId
}
return Boolean(evidence.tabId && evidence.tabId === source.tabId)
}
function mergeWorktreeSummaryStatus(
summary: RuntimeWorktreePsSummary,
next: RuntimeWorktreeStatus,
nextWorkingMode?: RuntimeWorktreePsSummary['workingMode']
): void {
const currentPriority = WORKTREE_STATUS_PRIORITY[summary.status]
const nextPriority = WORKTREE_STATUS_PRIORITY[next]
if (nextPriority > currentPriority) {
summary.status = next
if (next === 'working' && nextWorkingMode === 'monitoring') {
summary.workingMode = 'monitoring'
} else {
delete summary.workingMode
}
return
}
if (nextPriority === currentPriority && next === 'working') {
if (nextWorkingMode === 'monitoring') {
summary.workingMode = 'monitoring'
} else {
delete summary.workingMode
}
}
}
function normalizeTerminalChunk(
@@ -50,6 +50,7 @@ type CapturedStatus = {
connectionId: string | null
payload: {
state: string
workingMode?: 'monitoring'
prompt: string
agentType?: string
toolName?: string
@@ -184,6 +185,7 @@ function captureAgentStatuses(events: CapturedStatus[]): void {
connectionId: event.connectionId,
payload: {
state: event.payload.state,
...(event.payload.workingMode ? { workingMode: event.payload.workingMode } : {}),
prompt: event.payload.prompt,
agentType: event.payload.agentType,
toolName: event.payload.toolName
@@ -298,6 +300,42 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => {
})
})
it('preserves Claude monitoring mode across the SSH relay boundary', async () => {
relay = createFakeRelay()
vi.mocked(deployAndLaunchRelay).mockResolvedValue({
transport: relay.transport,
serverBuildId: 'test-relay-build',
platform: 'linux-x64'
})
const events: CapturedStatus[] = []
captureAgentStatuses(events)
session = createSession('conn-monitoring')
await session.establish({} as SshConnection)
relay.notifyAgentHook(
makeEnvelope({
source: 'claude',
claudeRunningNonAgentTask: true,
payload: {
state: 'working',
workingMode: 'monitoring',
prompt: 'watch the build',
agentType: 'claude'
}
})
)
await waitForStatusCount(events, 1)
expect(events[0]).toMatchObject({
connectionId: 'conn-monitoring',
payload: { state: 'working', workingMode: 'monitoring', agentType: 'claude' }
})
expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
workingMode: 'monitoring'
})
})
it('stamps SSH ownership and settles only the exact manual compact identity', async () => {
relay = createFakeRelay()
vi.mocked(deployAndLaunchRelay).mockResolvedValue({
+1 -1
View File
@@ -99,7 +99,7 @@ describe('RelayAgentHookServer', () => {
expect(forward.mock.calls[0][0]).toMatchObject({
claudeRunningNonAgentTask: true,
payload: { state: 'working', agentType: 'claude' }
payload: { state: 'working', workingMode: 'monitoring', agentType: 'claude' }
})
} finally {
server.stop()
@@ -42,6 +42,15 @@ describe('AgentStateDot', () => {
expect(markup).toContain('motion-reduce:border-t-yellow-500')
})
it('renders monitoring as a static yellow radio glyph', () => {
const markup = renderMarkup('monitoring')
expect(markup).toContain('aria-label="Monitoring background tasks"')
expect(markup).toContain('lucide-radio')
expect(markup).toContain('text-yellow-500')
expect(markup).not.toContain('data-agent-spinner')
})
it('renders done as an emerald check icon', () => {
const markup = renderMarkup('done')
+15 -1
View File
@@ -1,5 +1,5 @@
import React from 'react'
import { CircleCheck } from 'lucide-react'
import { CircleCheck, Radio } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
@@ -17,6 +17,7 @@ import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
export type AgentDotState =
| 'working'
| 'monitoring'
| 'blocked'
| 'waiting'
| 'interrupted'
@@ -37,6 +38,8 @@ export function agentStateLabel(state: AgentDotState): string {
switch (state) {
case 'working':
return 'Working'
case 'monitoring':
return 'Monitoring background tasks'
case 'blocked':
return 'Blocked'
case 'waiting':
@@ -81,6 +84,17 @@ export const AgentStateDot = React.memo(function AgentStateDot({
)
}
if (state === 'monitoring') {
return (
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
aria-label={agentStateLabel(state)}
>
<Radio className={cn('text-yellow-500', icon)} aria-hidden="true" />
</span>
)
}
if (state === 'done') {
// Why: the dashboard lists many agents, so a check glyph scans well for
// agent-reported completion and keeps 'done' visually distinct from
@@ -156,6 +156,27 @@ describe('buildActivityEvents', () => {
})
})
it('projects monitoring into thread rows and status groups', () => {
const result = makeActivityResult({
entries: {
[PANE_KEY]: {
...makeWorkingEntryWithoutHistory(),
workingMode: 'monitoring'
}
}
})
const threads = makeThreads(result)
const groups = groupActivityThreadsByStatus(threads)
expect(result.liveAgentByPaneKey[PANE_KEY].state).toBe('monitoring')
expect(threads[0].currentAgentState).toBe('monitoring')
expect(groups[0]).toMatchObject({
id: 'monitoring',
label: 'Monitoring background tasks',
state: 'monitoring'
})
})
it('uses orchestration display metadata for live thread titles', () => {
const result = makeActivityResult({
entries: {
@@ -11,7 +11,7 @@ import {
TerminalSquare
} from 'lucide-react'
import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot'
import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot'
import { AgentIcon } from '@/lib/agent-catalog'
import {
agentTypeToIconAgent,
@@ -88,8 +88,15 @@ import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
type ThreadReadFilter = 'all' | 'unread'
type ActivityGroupBy = 'status' | 'project' | 'worktree' | 'agent'
type ActivityEventState = Extract<AgentStatusState, 'done' | 'blocked' | 'waiting'>
type ActivityLiveAgentState = Extract<AgentStatusState, 'working' | 'blocked' | 'waiting'>
type ActivityStatusGroupId = 'working' | 'blocked' | 'waiting' | 'done' | 'interrupted'
type ActivityHookLiveAgentState = Extract<AgentStatusState, 'working' | 'blocked' | 'waiting'>
type ActivityLiveAgentState = ActivityHookLiveAgentState | 'monitoring'
type ActivityStatusGroupId =
| 'working'
| 'monitoring'
| 'blocked'
| 'waiting'
| 'done'
| 'interrupted'
type ActivityEvent = {
id: string
@@ -137,7 +144,7 @@ type ActivityThreadGroup = {
key: string
id?: ActivityStatusGroupId
label: string
state?: AgentStatusState
state?: AgentDotState
threads: AgentPaneThread[]
}
@@ -158,6 +165,7 @@ const ACTIVITY_TERMINAL_LOADING_LABEL_DELAY_MS = 180
const ACTIVITY_THREAD_RESPONSE_RENDER_PREVIEW_MAX_LENGTH = 320
const ACTIVITY_STATUS_GROUP_ORDER: ActivityStatusGroupId[] = [
'working',
'monitoring',
'blocked',
'waiting',
'done',
@@ -474,7 +482,9 @@ function isActivityEventState(state: AgentStatusState): state is ActivityEventSt
return state === 'done' || state === 'blocked' || state === 'waiting'
}
function isActivityLiveAgentState(state: AgentStatusState): state is ActivityLiveAgentState {
function isActivityHookLiveAgentState(
state: AgentStatusState
): state is ActivityHookLiveAgentState {
return state === 'working' || state === 'blocked' || state === 'waiting'
}
@@ -482,10 +492,15 @@ function freshActivityLiveAgentState(
entry: AgentStatusEntry,
now: number
): ActivityLiveAgentState | null {
if (!isActivityLiveAgentState(entry.state)) {
if (
!isActivityHookLiveAgentState(entry.state) ||
!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)
) {
return null
}
return isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) ? entry.state : null
return entry.state === 'working' && entry.workingMode === 'monitoring'
? 'monitoring'
: entry.state
}
function standaloneActivityWorktree(worktreeId: string): Worktree {
@@ -822,7 +837,7 @@ export function buildAgentPaneThreads(args: {
agentType: liveAgent.agentType,
currentAgentState: liveAgent.state,
currentAgentEntry: liveAgent.entry,
responsePreview: statusPreviewForEntry(liveAgent.entry, liveAgent.state),
responsePreview: statusPreviewForEntry(liveAgent.entry, liveAgent.entry.state),
latestTimestamp: liveAgent.timestamp,
latestEvent: null,
events: [],
@@ -840,7 +855,7 @@ export function buildAgentPaneThreads(args: {
existing.currentAgentEntry = liveAgent.entry
existing.responsePreview = statusPreviewForEntry(
liveAgent.entry,
liveAgent.state,
liveAgent.entry.state,
existing.responsePreview
)
existing.latestTimestamp = liveAgent.timestamp
@@ -960,7 +975,7 @@ function EventRepoBadge({ repo }: { repo: Repo | null }): React.JSX.Element | nu
)
}
function threadAgentState(thread: AgentPaneThread): AgentStatusState {
function threadAgentState(thread: AgentPaneThread): AgentDotState {
return thread.currentAgentState ?? thread.latestEvent?.state ?? 'done'
}
@@ -1024,10 +1039,12 @@ function threadStatusGroupId(thread: AgentPaneThread): ActivityStatusGroupId {
if (!thread.currentAgentState && state === 'done' && thread.latestEvent?.entry.interrupted) {
return 'interrupted'
}
return state === 'working' || state === 'blocked' || state === 'waiting' ? state : 'done'
return state === 'working' || state === 'monitoring' || state === 'blocked' || state === 'waiting'
? state
: 'done'
}
function threadStatusGroupState(id: ActivityStatusGroupId): AgentStatusState {
function threadStatusGroupState(id: ActivityStatusGroupId): AgentDotState {
return id === 'interrupted' ? 'done' : id
}
@@ -37,20 +37,27 @@ function makeTerminalTab(id: string, worktreeId: string): TerminalTab {
}
}
function makeAgentEntry(tabId: string, state: AgentStatusState): AgentStatusEntry {
function makeAgentEntry(
tabId: string,
state: AgentStatusState,
overrides: Partial<AgentStatusEntry> = {}
): AgentStatusEntry {
return {
state,
prompt: '',
updatedAt: Date.now(),
stateStartedAt: Date.now(),
paneKey: makePaneKey(tabId, LEAF),
stateHistory: []
stateHistory: [],
...overrides
}
}
function setAgentState(state: AgentStatusState): void {
function setAgentState(state: AgentStatusState, overrides: Partial<AgentStatusEntry> = {}): void {
useAppStore.setState((s) => ({
agentStatusByPaneKey: { [makePaneKey('term-a', LEAF)]: makeAgentEntry('term-a', state) },
agentStatusByPaneKey: {
[makePaneKey('term-a', LEAF)]: makeAgentEntry('term-a', state, overrides)
},
agentStatusEpoch: s.agentStatusEpoch + 1
}))
}
@@ -118,6 +125,21 @@ describe('palette live status', () => {
expect(dotLabels()).toEqual(['Needs permission'])
})
it('shows monitoring when a covered pane retains a working title', async () => {
setAgentState('working', { workingMode: 'monitoring' })
useAppStore.setState({
tabsByWorktree: {
'wt-a': [{ ...makeTerminalTab('term-a', 'wt-a'), title: 'claude [working]' }]
}
} as Partial<AppState>)
await render()
expect(testContainer.querySelector('[data-spinner]')).toBeNull()
expect(testContainer.querySelector('.lucide-radio')?.classList).toContain('text-yellow-500')
expect(dotLabels()).toEqual(['Monitoring background tasks'])
})
// Why this is the whole point: the palette body no longer subscribes to agent status, so if the
// dot didn't hold its own subscription it would freeze at whatever it showed when you opened.
it('re-renders the dot without re-rendering the frozen body around it', async () => {
@@ -234,6 +256,31 @@ describe('palette live status', () => {
expect(testContainer.querySelector('[title="Needs permission"]')).not.toBeNull()
})
it('shows monitoring with a static radio instead of the working spinner', async () => {
setAgentState('working', { workingMode: 'monitoring' })
await act(async () => {
testRoot.render(
<PaletteLiveStatusProvider active>
<PaletteRecentTabStatusDot
row={{
id: 'workspace-tab:tab-a',
worktreeId: 'wt-a',
unifiedTabId: 'tab-a',
terminalTab: { id: 'term-a', title: 'Chat' },
worktreeLastActivityAt: 0
}}
fallback={<span data-fallback="true" />}
/>
</PaletteLiveStatusProvider>
)
})
expect(testContainer.querySelector('[data-spinner]')).toBeNull()
expect(testContainer.querySelector('.lucide-radio')?.classList).toContain('text-yellow-500')
expect(dotLabels()).toEqual(['Monitoring background tasks'])
expect(testContainer.querySelector('[title="Monitoring background tasks"]')).not.toBeNull()
})
it('shows only the content icon when a terminal-backed row is inactive', async () => {
useAppStore.setState({
ptyIdsByTabId: {}
@@ -9,6 +9,7 @@ import {
type TabPaneInputSources
} from '@/components/sidebar/smart-attention'
import { cn } from '@/lib/utils'
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
import { getLiveAgentStatusByWorktreeId } from '@/lib/worktree-activity-state'
import {
getWorktreeStatus,
@@ -26,6 +27,11 @@ import {
} from '@/components/tab-bar/terminal-tab-activity-status'
import { translate } from '@/i18n/i18n'
import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type { Worktree } from '../../../../shared/worktree/types'
@@ -33,6 +39,7 @@ import type { Worktree } from '../../../../shared/worktree/types'
/** Confines the app's hottest status subscriptions here so only the dots re-render on their churn. */
type PaletteLiveStatus = {
liveAgentStatusByWorktreeId: ReadonlyMap<string, LiveAgentWorktreeStatus>
agentStatusPaneIdsByTabId: Record<string, ReadonlySet<string>>
paneSources: TabPaneInputSources
tabsByWorktree: Record<string, TerminalTab[]>
browserTabsByWorktree: Record<string, BrowserWorkspace[]>
@@ -85,17 +92,19 @@ export function PaletteLiveStatusProvider({
// Why: `now` decides freshness, so both derivations must read it on the same tick — otherwise a
// "done" dot can outlive its window while the worktree row beside it has already decayed.
const now = Date.now()
const entriesByTabId = buildExplicitEntriesByTabId(
agentStatusByPaneKey,
migrationUnsupportedByPtyId
)
return {
liveAgentStatusByWorktreeId: getLiveAgentStatusByWorktreeId(
agentStatusByPaneKey,
tabsByWorktree,
now
),
agentStatusPaneIdsByTabId: buildLiveAgentStatusPaneIdsByTabId(entriesByTabId, now),
paneSources: {
entriesByTabId: buildExplicitEntriesByTabId(
agentStatusByPaneKey,
migrationUnsupportedByPtyId
),
entriesByTabId,
ptyIdsByTabId,
runtimePaneTitlesByTabId,
terminalLayoutsByTabId
@@ -124,6 +133,32 @@ export function PaletteLiveStatusProvider({
)
}
function buildLiveAgentStatusPaneIdsByTabId(
entriesByTabId: ReadonlyMap<string, readonly AgentStatusEntry[]>,
now: number
): Record<string, ReadonlySet<string>> {
const paneIdsByTabId: Record<string, ReadonlySet<string>> = {}
for (const [tabId, entries] of entriesByTabId) {
const paneIds = new Set<string>()
for (const entry of entries) {
if (
entry.restoredUnconfirmed !== true &&
!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)
) {
continue
}
const paneId = parsePaneKey(entry.paneKey)?.leafId
if (paneId) {
paneIds.add(paneId)
}
}
if (paneIds.size > 0) {
paneIdsByTabId[tabId] = paneIds
}
}
return paneIdsByTabId
}
const EMPTY_LIVE_INPUTS = Object.freeze({
agentStatusByPaneKey: {},
runtimePaneTitlesByTabId: {},
@@ -158,7 +193,10 @@ export function PaletteWorktreeStatusDot({
live.browserTabsByWorktree[worktree.id] ?? [],
live.paneSources.ptyIdsByTabId,
live.paneSources.runtimePaneTitlesByTabId,
{ liveAgentStatus: live.liveAgentStatusByWorktreeId.get(worktree.id) }
{
liveAgentStatus: live.liveAgentStatusByWorktreeId.get(worktree.id),
agentStatusPaneIdsByTabId: live.agentStatusPaneIdsByTabId
}
)
return (
<>
@@ -10,6 +10,7 @@ import { TooltipProvider } from '@/components/ui/tooltip'
import { AgentKanbanCard } from './AgentKanbanCard'
const agentIconRender = vi.fn()
const agentStateDotRender = vi.fn()
vi.mock('@/lib/agent-catalog', () => ({
AgentIcon: () => {
@@ -19,7 +20,10 @@ vi.mock('@/lib/agent-catalog', () => ({
}))
vi.mock('@/components/AgentStateDot', () => ({
AgentStateDot: () => <span data-testid="state-dot" />
AgentStateDot: ({ state }: { state: string }) => {
agentStateDotRender(state)
return <span data-testid="state-dot" />
}
}))
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
@@ -284,6 +288,24 @@ describe('AgentKanbanCard', () => {
expect(screen.getByText('2m')).toBeInTheDocument()
})
it('rerenders when a working card enters monitoring', () => {
const initial = card()
const { rerender } = renderCard({ card: initial, now: 2_000 })
expect(agentStateDotRender).toHaveBeenLastCalledWith('working')
rerender(
<TooltipProvider>
<AgentKanbanCard
card={{ ...initial, workingMode: 'monitoring' }}
now={2_000}
onOpenTerminal={vi.fn()}
/>
</TooltipProvider>
)
expect(agentStateDotRender).toHaveBeenLastCalledWith('monitoring')
})
it('rerenders when the repo icon changes', () => {
const onOpenTerminal = vi.fn()
const initial = card({ startedAt: 1_000 })
@@ -87,6 +87,7 @@ function sameCard(a: DashboardCard, b: DashboardCard): boolean {
a.agentType === b.agentType &&
a.bucket === b.bucket &&
a.dotState === b.dotState &&
a.workingMode === b.workingMode &&
a.task === b.task &&
a.lastUserMessage === b.lastUserMessage &&
a.lastAgentMessage === b.lastAgentMessage &&
@@ -84,6 +84,7 @@ describe('AgentMapScene project labels', () => {
agents: [],
statusCounts: {
working: 0,
monitoring: 0,
blocked: 0,
waiting: 0,
done: 0,
@@ -63,7 +63,10 @@ function WorktreeDetails({
onDone: () => void
}): React.JSX.Element {
const activeCount =
worktree.statusCounts.working + worktree.statusCounts.blocked + worktree.statusCounts.waiting
worktree.statusCounts.working +
worktree.statusCounts.monitoring +
worktree.statusCounts.blocked +
worktree.statusCounts.waiting
const doneCount = worktree.statusCounts.done + worktree.statusCounts['done-seen']
return (
<PopoverContent align="center" sideOffset={10} className="w-80 p-0">
@@ -22,6 +22,9 @@ export function agentMapState(card: DashboardCard): AgentMapState {
if (state === 'done-seen') {
return 'done'
}
if (state === 'monitoring') {
return 'working'
}
return state
}
@@ -137,7 +137,12 @@ function baselineBox(
* idle neighbour has to drop its own. */
function labelPriority(worktree: AgentMapWorktreeRing): number {
const attention = worktree.statusCounts.blocked + worktree.statusCounts.waiting
return attention * 1_000_000 + worktree.statusCounts.working * 10_000 + worktree.agents.length
return (
attention * 1_000_000 +
worktree.statusCounts.working * 10_000 +
worktree.statusCounts.monitoring * 1_000 +
worktree.agents.length
)
}
/** Worth drawing before collisions are considered: all-idle workspaces stay
@@ -551,6 +551,9 @@ describe('agent map layout', () => {
for (const dotState of ['working', 'blocked', 'waiting', 'idle'] as const) {
expect(agentMapNodeStatus(card({ dotState }))).toBe(dotState)
}
expect(agentMapNodeStatus(card({ dotState: 'working', workingMode: 'monitoring' }))).toBe(
'monitoring'
)
expect(agentMapNodeStatus(card({ dotState: 'done', unseen: true }))).toBe('done')
expect(agentMapNodeStatus(card({ dotState: 'done', unseen: false }))).toBe('done-seen')
const shortBlocked = card({ dotState: 'blocked', startedAt: NOW - 60_000 })
@@ -1,6 +1,7 @@
import {
dashboardCardDisplayState,
type DashboardCard,
type DashboardCardDisplayState,
type DashboardCardDotState
} from '../../../../shared/dashboard-snapshot'
@@ -8,7 +9,7 @@ import {
* acknowledged finish into `idle`, which is right for bucket counts but loses the one
* distinction the map exists to show: finished-and-unread vs finished-and-still-yours.
* Kept local so `DashboardCardDotState` — which crosses the pop-out bridge — is unchanged. */
export type AgentMapNodeStatus = DashboardCardDotState | 'done-seen'
export type AgentMapNodeStatus = DashboardCardDisplayState | 'done-seen'
export function agentMapDurationMinutes(card: DashboardCard, now: number): number {
if (!Number.isFinite(card.startedAt) || card.startedAt <= 0) {
@@ -89,7 +90,7 @@ export function selectAgentMapRecentFlareStatuses(
export type AgentMapStatusCounts = Record<AgentMapNodeStatus, number>
export function emptyAgentMapStatusCounts(): AgentMapStatusCounts {
return { working: 0, blocked: 0, waiting: 0, done: 0, 'done-seen': 0, idle: 0 }
return { working: 0, monitoring: 0, blocked: 0, waiting: 0, done: 0, 'done-seen': 0, idle: 0 }
}
/** Finished work you have already opened is still yours to land, but it is not asking for
@@ -35,4 +35,9 @@ describe('agentMapWorktreeActiveStatus', () => {
expect(agentMapWorktreeActiveStatus(counts({ done: 2, idle: 1 }))).toBe('done')
expect(agentMapWorktreeActiveStatus(counts({ 'done-seen': 2, idle: 1 }))).toBeNull()
})
it('keeps passive monitoring out of the active-worktree glow', () => {
expect(agentMapWorktreeActiveStatus(counts({ monitoring: 1 }))).toBeNull()
expect(agentMapWorktreeActiveStatus(counts({ working: 1, monitoring: 1 }))).toBe('working')
})
})
@@ -420,6 +420,10 @@
stroke: var(--color-yellow-500);
}
.fleet-status-monitoring .agent-map-agent-mark {
stroke: var(--color-yellow-500);
}
.fleet-status-blocked .agent-map-agent-mark {
stroke: var(--color-red-500);
}
@@ -86,6 +86,19 @@ describe('patchDashboardSnapshotFromAgentStatus', () => {
})
})
it('patches same-state working into passive monitoring', () => {
const result = patchDashboardSnapshotFromAgentStatus(
snapshot(),
event({ state: 'working', workingMode: 'monitoring', stateStartedAt: 100 })
)
expect(result.snapshot.cards[0]).toMatchObject({
bucket: 'working',
dotState: 'working',
workingMode: 'monitoring'
})
})
it('ignores stale, wrong-workspace, and session-only events', () => {
const original = snapshot()
expect(
@@ -49,7 +49,11 @@ export function patchDashboardSnapshotFromAgentStatus(
const stateChanged = event.stateStartedAt > card.stateChangedAt
const unseen = stateChanged ? card.startedAt !== 0 : card.unseen
const dotState = event.state
const bucket = dashboardBucketForDotState(dashboardCardDisplayState({ dotState, unseen }))
const workingMode =
event.state === 'working' && event.workingMode === 'monitoring' ? event.workingMode : undefined
const bucket = dashboardBucketForDotState(
dashboardCardDisplayState({ dotState, workingMode, unseen })
)
const nextCard: DashboardCard = {
...card,
...(event.agentType ? { agentType: event.agentType } : {}),
@@ -62,6 +66,7 @@ export function patchDashboardSnapshotFromAgentStatus(
: {}),
bucket,
dotState,
workingMode,
unseen,
stateChangedAt: stateChanged ? event.stateStartedAt : card.stateChangedAt,
statusUpdatedAt: event.receivedAt,
@@ -4,9 +4,10 @@ import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot'
import type {
AgentStatusClearIpcPayload,
AgentStatusIpcPayload
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusClearIpcPayload,
type AgentStatusIpcPayload
} from '../../../../shared/agent-status-types'
import { useDashboardSnapshot } from './useDashboardSnapshot'
@@ -238,4 +239,31 @@ describe('useDashboardSnapshot', () => {
expect(requestSnapshot).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
it('refreshes when a monitoring card crosses the stale boundary', () => {
vi.useFakeTimers()
const statusUpdatedAt = 1_000
vi.setSystemTime(statusUpdatedAt)
renderHook(() => useDashboardSnapshot())
act(() =>
apply(
snapshot([
card({
dotState: 'working',
workingMode: 'monitoring',
statusUpdatedAt
})
])
)
)
requestSnapshot.mockClear()
act(() => vi.advanceTimersByTime(AGENT_STATUS_STALE_AFTER_MS))
expect(requestSnapshot).not.toHaveBeenCalled()
act(() => vi.advanceTimersByTime(250))
expect(requestSnapshot).not.toHaveBeenCalled()
act(() => vi.advanceTimersByTime(1))
expect(requestSnapshot).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
})
@@ -348,6 +348,26 @@ describe('DashboardAgentRow', () => {
expect(activeToolMarkup).toContain('ListDir')
})
it('renders monitoring without a spinner or active tool line', () => {
const markup = renderRow(
makeAgent(
{},
{
workingMode: 'monitoring',
prompt: '',
toolName: 'Bash',
toolInput: 'pnpm dev'
}
)
)
expect(markup).toContain('Monitoring background tasks')
expect(markup).toContain('lucide-radio')
expect(classTokens(markup)).toContain('text-yellow-500')
expect(markup).not.toContain('data-agent-spinner')
expect(markup).not.toContain('data-agent-row-tool-slot')
})
it('names what a blocked approval is waiting on', () => {
const markup = renderRow(
makeAgent(
@@ -16,9 +16,13 @@ import { useAgentRowConversationName } from './use-agent-row-conversation-name'
import { lastEnteredDoneAt } from './agent-finished-timestamp'
// Why: narrow the dashboard's rollup states to shared dot states, defaulting unknowns to 'idle' so a row never crashes.
function asDotState(state: AgentStatusState | 'idle'): AgentDotState {
function asDotState(
state: AgentStatusState | 'idle',
workingMode?: DashboardAgentRowData['entry']['workingMode']
): AgentDotState {
switch (state) {
case 'working':
return workingMode === 'monitoring' ? 'monitoring' : 'working'
case 'blocked':
case 'waiting':
case 'done':
@@ -143,12 +147,14 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
const conversationName = useAgentRowConversationName(agent)
const prompt = conversationName ?? getAgentRowPrimaryText(agent.entry)
// Why: prompt is '' when unknown, so fall back to the state label to keep the row labeled.
const displayLabel = prompt || agentStateLabel(asDotState(agent.state))
const displayLabel = prompt || agentStateLabel(asDotState(agent.state, agent.entry.workingMode))
const model = agent.entry.model?.trim() ?? ''
const isWorking = agent.state === 'working'
const isMonitoring = agent.state === 'working' && agent.entry.workingMode === 'monitoring'
const isWorking = agent.state === 'working' && !isMonitoring
// Why: 'working' names the running tool and 'waiting' names what an approval is blocked on;
// anywhere else a leftover tool line reads as still-running. See showsAgentToolPreview.
const showsTool = showsAgentToolPreview(agent.state)
// Monitoring is excluded too: the lead turn is over, so its last tool line is stale.
const showsTool = showsAgentToolPreview(agent.state) && !isMonitoring
const toolName = showsTool ? (agent.entry.toolName?.trim() ?? '') : ''
const toolInput = showsTool ? (agent.entry.toolInput?.trim() ?? '') : ''
const lastAssistantMessage = agent.entry.lastAssistantMessage?.trim() ?? ''
@@ -164,7 +170,9 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
}`
: [formatAgentTypeLabel(agent.agentType), model].filter(Boolean).join(' · ')
// Why: interrupted is a terminal outcome, so surface it in the leading state dot.
const dotState: AgentDotState = isInterrupted ? 'interrupted' : asDotState(agent.state)
const dotState: AgentDotState = isInterrupted
? 'interrupted'
: asDotState(agent.state, agent.entry.workingMode)
const dotTooltipLabel = stateDotTooltipLabel(agent, dotState)
// Why: always show the chevron so the row's right edge doesn't flicker as content grows/shrinks.
@@ -201,6 +201,23 @@ describe('buildDashboardSnapshot', () => {
expect(card.unseen).toBe(true)
})
it('keeps monitoring in the working bucket with a passive dot state', () => {
const snapshot = buildDashboardSnapshot(
baseState({
agentStatusByPaneKey: {
[PANE_KEY]: entry({ state: 'working', workingMode: 'monitoring' })
}
}),
NOW
)
expect(snapshot.cards[0]).toMatchObject({
bucket: 'working',
dotState: 'working',
workingMode: 'monitoring'
})
})
it('publishes terminal-backed orchestrated workers under their direct parent', () => {
const snapshot = buildDashboardSnapshot(
baseState({
@@ -210,10 +210,16 @@ export function buildDashboardSnapshot(
? layoutPtyId
: null
const dotState = row.state as DashboardCardDotState
const workingMode =
row.state === 'working' && row.entry.workingMode === 'monitoring'
? row.entry.workingMode
: undefined
const unseen =
!isTitleDerived &&
(state.acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt
const bucket = dashboardBucketForDotState(dashboardCardDisplayState({ dotState, unseen }))
const bucket = dashboardBucketForDotState(
dashboardCardDisplayState({ dotState, workingMode, unseen })
)
// Why: only a live pty can open a preview terminal, and only a
// card-rendering caller can open one — the sidebar's bucket counts must
// not pay host resolution on every agent-status tick.
@@ -249,6 +255,7 @@ export function buildDashboardSnapshot(
agentType: row.agentType,
bucket,
dotState,
...(workingMode ? { workingMode } : {}),
task: isTitleDerived ? '' : rowTask(row),
repoId: workspace.projectId,
worktreeId,
@@ -1,8 +1,12 @@
import type { DashboardBucket, DashboardCardDotState } from '../../../../shared/dashboard-snapshot'
import type {
DashboardBucket,
DashboardCardDisplayState
} from '../../../../shared/dashboard-snapshot'
export function dashboardBucketForDotState(state: DashboardCardDotState): DashboardBucket {
export function dashboardBucketForDotState(state: DashboardCardDisplayState): DashboardBucket {
switch (state) {
case 'working':
case 'monitoring':
return 'working'
case 'done':
return 'done'
@@ -245,7 +245,7 @@ function AgentTargetMenuItem({
onSend: (target: NotesSendAgentTarget) => void
}): React.JSX.Element {
const tabTitle = target.tabTitle.trim()
const state = asDotState(agent?.state ?? 'idle')
const state = asDotState(agent?.state ?? 'idle', agent?.entry.workingMode)
const timeAgo = agent ? formatAgentRelativeTime(agent, now) : null
const secondaryParts = [
agentStateLabel(state),
@@ -302,9 +302,13 @@ function orderSendTargetsByWorktreeAgentRows(
return ordered
}
function asDotState(state: AgentStatusState | 'idle'): AgentDotState {
function asDotState(
state: AgentStatusState | 'idle',
workingMode?: DashboardAgentRowData['entry']['workingMode']
): AgentDotState {
switch (state) {
case 'working':
return workingMode === 'monitoring' ? 'monitoring' : 'working'
case 'blocked':
case 'waiting':
case 'done':
@@ -6,7 +6,12 @@ export function useNativeChatHookStatus(
): readonly [AgentStatusState | null, number | null, boolean] {
// Why: primitive selectors keep unrelated pane/status updates from rerendering
// native chat while still exposing the three fields used for reconciliation.
const state = useAppStore((store) => store.agentStatusByPaneKey[paneKey]?.state ?? null)
const state = useAppStore((store) => {
const entry = store.agentStatusByPaneKey[paneKey]
return entry?.state === 'working' && entry.workingMode === 'monitoring'
? null
: (entry?.state ?? null)
})
const stateStartedAt = useAppStore(
(store) => store.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null
)
@@ -401,6 +401,32 @@ describe('useNativeChatLiveSession — transport routing', () => {
expect(latest?.status).toBe('ready')
})
it('stops foreground working UI when Claude enters monitoring', async () => {
useAppStore.setState({
agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never }
})
const transport = getMockTransport('env-1')
await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' })
await act(async () =>
transport.emit({
type: 'snapshot',
messages: [user('u-1', 'run it')],
hasMore: false
})
)
expect(latest?.status).toBe('working')
await act(async () => {
useAppStore.setState({
agentStatusByPaneKey: {
[PANE]: { state: 'working', workingMode: 'monitoring', stateStartedAt: 1 } as never
}
})
})
expect(latest?.status).toBe('ready')
})
it('applies a lifecycle-only append after the final message frame', async () => {
useAppStore.setState({
agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never }
@@ -46,6 +46,14 @@ describe('selectPetAnimationName', () => {
expect(select([entry('working')])).toBe('running')
})
it('keeps passive monitoring out of the running animation', () => {
const monitoring = entry('working', { workingMode: 'monitoring' })
expect(select([monitoring])).toBe('idle')
expect(select([monitoring, entry('working')])).toBe('running')
expect(select([monitoring, entry('done')])).toBe('review')
})
it('maps blocked and waiting states to waiting', () => {
expect(select([entry('blocked')])).toBe('waiting')
expect(select([entry('waiting')])).toBe('waiting')
@@ -53,7 +53,7 @@ function agentStateAnimation(
if (entry.state === 'blocked' || entry.state === 'waiting') {
return 'waiting'
}
if (entry.state === 'working') {
if (entry.state === 'working' && entry.workingMode !== 'monitoring') {
hasWorking = true
} else if (entry.state === 'done') {
hasDone = true
@@ -31,6 +31,15 @@ describe('StatusIndicator', () => {
expect(markup).toContain('motion-reduce:border-t-yellow-500')
})
it('renders monitoring as a static radio glyph', () => {
const markup = renderMarkup('monitoring')
expect(markup).toContain('title="Monitoring background tasks"')
expect(markup).toContain('lucide-radio')
expect(markup).toContain('text-yellow-500')
expect(markup).not.toContain('data-agent-spinner')
})
it('renders permission as the shared question glyph', () => {
const markup = renderMarkup('permission')
@@ -1,4 +1,5 @@
import React from 'react'
import { Radio } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
@@ -40,6 +41,18 @@ const StatusIndicator = React.memo(function StatusIndicator({
)
}
if (status === 'monitoring') {
return (
<span
className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}
title={resolvedTitle}
{...rest}
>
<Radio className="size-3 text-yellow-500" aria-hidden="true" />
</span>
)
}
if (status === 'permission') {
return (
<span
@@ -22,8 +22,14 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
const terminalLayoutRootsByTabId = useAppStore(
useShallow((s) => selectTerminalLayoutRootsForWorktree(s, worktreeId))
)
const { hasPermission, hasLiveWorking, hasLiveDone, hasRetainedDone, agentStatusPaneIdsByTabId } =
useAppStore(useShallow((s) => selectWorktreeAgentActivitySummary(s, worktreeId)))
const {
hasPermission,
hasLiveWorking,
hasLiveMonitoring,
hasLiveDone,
hasRetainedDone,
agentStatusPaneIdsByTabId
} = useAppStore(useShallow((s) => selectWorktreeAgentActivitySummary(s, worktreeId)))
// Why: compact and detailed cards need the same status-dot semantics:
// runtime liveness gates title-derived states, then explicit agent rows can
@@ -39,6 +45,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
terminalLayoutRootsByTabId,
hasPermission,
hasLiveWorking,
hasLiveMonitoring,
hasLiveDone,
hasRetainedDone
}),
@@ -51,6 +58,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
terminalLayoutRootsByTabId,
hasPermission,
hasLiveWorking,
hasLiveMonitoring,
hasLiveDone,
hasRetainedDone
]
@@ -33,6 +33,7 @@ export function selectWorktreeActivityStatuses(
const {
hasPermission,
hasLiveWorking,
hasLiveMonitoring,
hasLiveDone,
hasRetainedDone,
agentStatusPaneIdsByTabId
@@ -48,6 +49,7 @@ export function selectWorktreeActivityStatuses(
terminalLayoutRootsByTabId: selectTerminalLayoutRootsForWorktree(statusInputs, worktreeId),
hasPermission,
hasLiveWorking,
hasLiveMonitoring,
hasLiveDone,
hasRetainedDone
})
@@ -16,6 +16,7 @@ function makeAgentStatusEntry(args: {
worktreeId?: string
parentPaneKey?: string
restoredUnconfirmed?: true
workingMode?: AgentStatusEntry['workingMode']
}): AgentStatusEntry {
return {
paneKey: args.paneKey,
@@ -26,6 +27,7 @@ function makeAgentStatusEntry(args: {
stateHistory: [],
worktreeId: args.worktreeId,
restoredUnconfirmed: args.restoredUnconfirmed,
workingMode: args.workingMode,
orchestration: args.parentPaneKey
? {
taskId: 'task-1',
@@ -167,6 +169,30 @@ describe('selectWorktreeAgentActivitySummary', () => {
expect(nowSpy).toHaveBeenCalledTimes(2)
})
it('separates passive monitoring from active working', () => {
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: 1,
agentStatusByPaneKey: {
[paneKey]: makeAgentStatusEntry({
paneKey,
state: 'working',
workingMode: 'monitoring'
})
},
migrationUnsupportedByPtyId: {},
runtimeAgentOrchestrationByPaneKey: {},
retainedAgentsByPaneKey: {}
},
'repo::/wt-1'
)
expect(summary).toMatchObject({ hasLiveWorking: false, hasLiveMonitoring: true })
})
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)
@@ -15,6 +15,7 @@ import {
export type WorktreeAgentActivitySummary = {
hasPermission: boolean
hasLiveWorking: boolean
hasLiveMonitoring: boolean
hasLiveDone: boolean
hasRetainedDone: boolean
agentStatusPaneIdsByTabId: Record<string, ReadonlySet<string>>
@@ -25,6 +26,7 @@ const EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID: Record<string, ReadonlySet<string>>
const EMPTY_SUMMARY: WorktreeAgentActivitySummary = {
hasPermission: false,
hasLiveWorking: false,
hasLiveMonitoring: false,
hasLiveDone: false,
hasRetainedDone: false,
agentStatusPaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID
@@ -177,6 +179,7 @@ function summariesEqual(
return (
previous.hasPermission === next.hasPermission &&
previous.hasLiveWorking === next.hasLiveWorking &&
previous.hasLiveMonitoring === next.hasLiveMonitoring &&
previous.hasLiveDone === next.hasLiveDone &&
previous.hasRetainedDone === next.hasRetainedDone &&
agentStatusPaneIdsByTabIdEqual(
@@ -214,12 +217,16 @@ function agentStatusPaneIdsByTabIdEqual(
function applyLiveAgentState(
summary: WorktreeAgentActivitySummary,
entry: Pick<AgentStatusEntry, 'state'>
entry: Pick<AgentStatusEntry, 'state' | 'workingMode'>
): void {
if (entry.state === 'blocked' || entry.state === 'waiting') {
summary.hasPermission = true
} else if (entry.state === 'working') {
summary.hasLiveWorking = true
if (entry.workingMode === 'monitoring') {
summary.hasLiveMonitoring = true
} else {
summary.hasLiveWorking = true
}
} else if (entry.state === 'done') {
summary.hasLiveDone = true
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import { getCompactAgentSecondary } from './worktree-card-compact-agent-row'
import { getAgentDotState, summarizeAgents } from './worktree-card-agent-summary'
function monitoringAgent(): DashboardAgentRowData {
return {
paneKey: 'tab-1:leaf-1',
state: 'working',
agentType: 'claude',
startedAt: 1,
tab: {
id: 'tab-1',
ptyId: null,
worktreeId: 'wt-1',
title: 'Claude',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
},
entry: {
state: 'working',
workingMode: 'monitoring',
prompt: '',
updatedAt: 1,
stateStartedAt: 1,
stateHistory: [],
paneKey: 'tab-1:leaf-1'
}
}
}
describe('worktree card agent summary', () => {
it('presents passive working as monitoring', () => {
const agent = monitoringAgent()
expect(getAgentDotState(agent)).toBe('monitoring')
expect(getCompactAgentSecondary(agent)).toBe('Monitoring background tasks')
expect(summarizeAgents([agent], 'Agent')).toBe('Agent monitoring')
})
})
@@ -13,6 +13,7 @@ const SUMMARY_STATE_ORDER: AgentDotState[] = [
'blocked',
'interrupted',
'working',
'monitoring',
'done',
'idle'
]
@@ -30,7 +31,12 @@ function asDotState(state: AgentStatusState | 'idle'): AgentDotState {
}
export function getAgentDotState(agent: DashboardAgentRowData): AgentDotState {
return agent.entry.interrupted === true ? 'interrupted' : asDotState(agent.state)
if (agent.entry.interrupted === true) {
return 'interrupted'
}
return agent.state === 'working' && agent.entry.workingMode === 'monitoring'
? 'monitoring'
: asDotState(agent.state)
}
export function formatSummaryStateLabel(state: AgentDotState): string {
@@ -45,6 +51,8 @@ export function formatSummaryStateLabel(state: AgentDotState): string {
return 'failed'
case 'working':
return 'working'
case 'monitoring':
return 'monitoring'
case 'done':
return 'done'
case 'idle':
@@ -37,10 +37,14 @@ function getCompactAgentPrimary(
return prompt || agentStateLabel(getAgentDotState(agent))
}
function getCompactAgentSecondary(agent: DashboardAgentRowData): string {
export function getCompactAgentSecondary(agent: DashboardAgentRowData): string {
if (agent.entry.interrupted === true) {
return 'Interrupted by user'
}
// Why: the lead turn is over in monitoring, so its last tool line is stale; name the state instead.
if (agent.state === 'working' && agent.entry.workingMode === 'monitoring') {
return agentStateLabel('monitoring')
}
const toolPreview = formatAgentToolPreview(agent.entry, agent.state)
if (toolPreview) {
return toolPreview
@@ -62,6 +62,28 @@ describe('resolveTerminalTabActivityStatus', () => {
).toBe('working')
})
it('reports monitoring without hiding active or actionable siblings', () => {
const monitoring = entry(FIRST_LEAF_ID, 'working', { workingMode: 'monitoring' })
const working = entry(SECOND_LEAF_ID, 'working')
expect(
resolveTerminalTabActivityStatus({
tab: TAB,
agentStatusByPaneKey: { [monitoring.paneKey]: monitoring },
ptyIdsByTabId: LIVE_PTY
})
).toBe('monitoring')
expect(
resolveTerminalTabActivityStatus({
tab: TAB,
agentStatusByPaneKey: {
[monitoring.paneKey]: monitoring,
[working.paneKey]: working
},
ptyIdsByTabId: LIVE_PTY
})
).toBe('working')
})
it('lets a needs-input pane outrank a working sibling', () => {
const working = entry(FIRST_LEAF_ID, 'working')
const waiting = entry(SECOND_LEAF_ID, 'waiting')
@@ -246,6 +268,9 @@ describe('resolveTerminalTabAttentionBadge', () => {
expect(resolveTerminalTabAttentionBadge({ status: 'permission', hasUnread: true })).toBe(
'permission'
)
expect(resolveTerminalTabAttentionBadge({ status: 'monitoring', hasUnread: true })).toBe(
'monitoring'
)
expect(resolveTerminalTabAttentionBadge({ status: 'done', hasUnread: true })).toBe('unread')
expect(resolveTerminalTabAttentionBadge({ status: 'done', hasUnread: false })).toBe('done')
expect(resolveTerminalTabAttentionBadge({ status: 'active', hasUnread: false })).toBeNull()
@@ -274,6 +299,7 @@ describe('terminalTabHasUnreadActivity', () => {
describe('terminalTabActivityToAgentDotState', () => {
it('maps glyph statuses and drops quiet ones', () => {
expect(terminalTabActivityToAgentDotState('working')).toBe('working')
expect(terminalTabActivityToAgentDotState('monitoring')).toBe('monitoring')
expect(terminalTabActivityToAgentDotState('permission')).toBe('permission')
expect(terminalTabActivityToAgentDotState('done')).toBe('done')
expect(terminalTabActivityToAgentDotState('active')).toBeNull()
@@ -19,6 +19,7 @@ export type TerminalTabActivityStatus = WorktreeStatus
type TerminalTabActivityFlags = {
hasPermission: boolean
hasLiveWorking: boolean
hasLiveMonitoring: boolean
hasLiveDone: boolean
paneIds: Set<string>
}
@@ -75,7 +76,11 @@ function getTerminalTabActivityFlags(
if (entry.state === 'blocked' || entry.state === 'waiting') {
flags.hasPermission = true
} else if (entry.state === 'working') {
flags.hasLiveWorking = true
if (entry.workingMode === 'monitoring') {
flags.hasLiveMonitoring = true
} else {
flags.hasLiveWorking = 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
@@ -97,6 +102,7 @@ function getOrCreateTerminalTabActivityFlags(
flags = {
hasPermission: false,
hasLiveWorking: false,
hasLiveMonitoring: false,
hasLiveDone: false,
paneIds: new Set()
}
@@ -157,6 +163,7 @@ export function resolveTerminalTabActivityStatus({
terminalLayoutsByTabId: terminalLayout ? { [tab.id]: terminalLayout } : undefined,
hasPermission: flags?.hasPermission ?? false,
hasLiveWorking: flags?.hasLiveWorking ?? false,
hasLiveMonitoring: flags?.hasLiveMonitoring ?? false,
hasLiveDone: flags?.hasLiveDone ?? false,
// Why: retained/orchestration promotions are worktree-aggregate concerns;
// a tab reflects its own live panes and title only.
@@ -166,14 +173,14 @@ export function resolveTerminalTabActivityStatus({
/** True while the tab shows a live in-turn signal (spinner or needs-input). */
export function isTerminalTabActivityLive(status: TerminalTabActivityStatus): boolean {
return status === 'working' || status === 'permission'
return status === 'working' || status === 'monitoring' || status === 'permission'
}
/**
* 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' | 'permission' | 'unread' | 'done'
export type TerminalTabAttentionBadge = 'working' | 'monitoring' | 'permission' | 'unread' | 'done'
/**
* Single priority ladder shared by the tab strip and Cmd+J recent rows:
@@ -192,6 +199,9 @@ export function resolveTerminalTabAttentionBadge({
if (status === 'permission') {
return 'permission'
}
if (status === 'monitoring') {
return 'monitoring'
}
if (hasUnread) {
return 'unread'
}
@@ -204,9 +214,10 @@ export function resolveTerminalTabAttentionBadge({
/** Map a container activity status onto AgentStateDot's vocabulary (no unread — that's a bell). */
export function terminalTabActivityToAgentDotState(
status: TerminalTabActivityStatus
): 'working' | 'permission' | 'done' | null {
): 'working' | 'monitoring' | 'permission' | 'done' | null {
switch (status) {
case 'working':
case 'monitoring':
case 'permission':
case 'done':
return status
@@ -139,7 +139,9 @@ export function buildWorkspaceCleanupFacets(
isPinned: worktree?.isPinned ?? candidate.blockers.includes('pinned'),
isUnread: worktree?.isUnread ?? false,
hasComment,
agentState: sources.liveAgentStatusByWorktreeId?.get(candidate.worktreeId) ?? 'idle',
agentState: toWorkspaceCleanupAgentState(
sources.liveAgentStatusByWorktreeId?.get(candidate.worktreeId)
),
retainedDoneAgentCount: candidate.localContext.retainedDoneAgentCount,
gitState: getWorkspaceCleanupGitState(candidate),
upstreamAhead: toFiniteOrNull(candidate.git.upstreamAhead),
@@ -168,6 +170,17 @@ export function countWorkspaceCleanupMeasuredRows(rows: readonly WorkspaceCleanu
return rows.reduce((count, row) => count + (row.sizeBytes === null ? 0 : 1), 0)
}
// Why: monitoring is still registered background work, so it filters as active — offering
// such a workspace as idle would invite cleaning up a running dev server (#10997).
function toWorkspaceCleanupAgentState(
status: LiveAgentWorktreeStatus | undefined
): WorkspaceCleanupAgentState {
if (status === undefined) {
return 'idle'
}
return status === 'monitoring' ? 'working' : status
}
function getLocalContextCount(candidate: WorkspaceCleanupCandidate): number {
const context = candidate.localContext
return (
@@ -253,6 +253,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
{
paneKey: FUTURE_PANE_KEY,
state: 'working' as const,
workingMode: 'monitoring' as const,
prompt: 'p',
agentType: 'claude',
receivedAt: 1_700_000_000_000,
@@ -340,7 +341,12 @@ describe('useIpcEvents agent status snapshot integration', () => {
expect(setAgentStatuses.mock.calls[0]?.[0]).toHaveLength(1)
expect(setAgentStatus).toHaveBeenCalledWith(
FUTURE_PANE_KEY,
expect.objectContaining({ state: 'working', prompt: 'p', agentType: 'claude' }),
expect.objectContaining({
state: 'working',
workingMode: 'monitoring',
prompt: 'p',
agentType: 'claude'
}),
'Future Tab',
{ updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 },
expectWorktreeRouting('wt-1'),
+1
View File
@@ -3310,6 +3310,7 @@ export function useIpcEvents(): void {
const ownerTabId = parsePaneKey(paneKey)?.tabId ?? data.tabId
const payload = normalizeAgentStatusPayload({
state: data.state,
workingMode: data.workingMode,
prompt: data.prompt,
agentType: data.agentType,
model: data.model,
@@ -240,6 +240,29 @@ describe('resolveRecentWorkspaceTabStatus', () => {
).toBe('working')
})
it('preserves monitoring unless another pane is actively working', () => {
const monitoring = row('monitoring')
const monitoringEntry = entry('monitoring', 'working', NOW, {
workingMode: 'monitoring'
})
expect(resolveRecentWorkspaceTabStatus(monitoring, sources([monitoringEntry]), NOW)).toBe(
'monitoring'
)
expect(
resolveRecentWorkspaceTabStatus(
monitoring,
sources([
monitoringEntry,
entry('monitoring', 'working', NOW, {
paneKey: 'monitoring:22222222-3333-4444-8555-666666666666'
})
]),
NOW
)
).toBe('working')
})
it('falls back to live-pty presence for idle rows', () => {
const live = row('live')
@@ -79,14 +79,24 @@ export function resolveRecentWorkspaceTabStatus(
paneSources: TabPaneInputSources,
now: number
): WorktreeStatus {
const attention = resolveRecentWorkspaceTabAttention(row, paneSources, now)
if (!row.terminalTab) {
return 'inactive'
}
const panes = collectTabPaneInputs(row.terminalTab, row.worktreeLastActivityAt, paneSources, now)
const attention = resolveAttention(panes, now)
const explicit = STATUS_BY_ATTENTION_CLASS[attention.cls]
if (explicit === 'working') {
const hasForegroundWork = panes.some(
(pane) =>
resolveAttention([pane], now).cls === 3 &&
(pane.kind === 'title' || pane.entry.workingMode !== 'monitoring')
)
return hasForegroundWork ? 'working' : 'monitoring'
}
if (explicit) {
return explicit
}
return row.terminalTab && tabHasLivePty(paneSources.ptyIdsByTabId, row.terminalTab.id)
? 'active'
: 'inactive'
return tabHasLivePty(paneSources.ptyIdsByTabId, row.terminalTab.id) ? 'active' : 'inactive'
}
/** `TabGroup.recentTabIds` keeps most-recent at the tail, so the index is the ordinal. */
@@ -223,4 +223,41 @@ describe('getWorktreeIdsWithLiveAgent', () => {
])
)
})
it('reports monitoring below active working and permission', () => {
const entries = {
'tab-1:leaf-1': makeAgentEntry({
paneKey: 'tab-1:leaf-1',
worktreeId: 'wt-1',
workingMode: 'monitoring'
}),
'tab-2:leaf-2': makeAgentEntry({
paneKey: 'tab-2:leaf-2',
worktreeId: 'wt-2',
workingMode: 'monitoring'
}),
'tab-3:leaf-3': makeAgentEntry({
paneKey: 'tab-3:leaf-3',
worktreeId: 'wt-2'
}),
'tab-4:leaf-4': makeAgentEntry({
paneKey: 'tab-4:leaf-4',
worktreeId: 'wt-3',
workingMode: 'monitoring'
}),
'tab-5:leaf-5': makeAgentEntry({
paneKey: 'tab-5:leaf-5',
worktreeId: 'wt-3',
state: 'waiting'
})
}
expect(getLiveAgentStatusByWorktreeId(entries, {}, NOW)).toEqual(
new Map([
['wt-1', 'monitoring'],
['wt-2', 'working'],
['wt-3', 'permission']
])
)
})
})
@@ -12,7 +12,7 @@ type BrowserLikeTab = { id: string }
type TabsByWorktree = Record<string, readonly TerminalLikeTab[]>
type PtyIdsByTabId = Record<string, string[]>
type BrowserTabsByWorktree = Record<string, readonly BrowserLikeTab[]>
export type LiveAgentWorktreeStatus = 'working' | 'permission'
export type LiveAgentWorktreeStatus = 'working' | 'monitoring' | 'permission'
/**
* Worktree ids that currently have a live agent session, derived from the
@@ -50,8 +50,18 @@ export function getLiveAgentStatusByWorktreeId(
for (const entry of entries) {
const worktreeId = resolveAgentStatusWorktreeId(entry, worktreeIdByTabId)
if (worktreeId) {
const status = entry.state === 'working' ? 'working' : 'permission'
if (status === 'permission' || !result.has(worktreeId)) {
const status =
entry.state === 'working'
? entry.workingMode === 'monitoring'
? 'monitoring'
: 'working'
: 'permission'
const current = result.get(worktreeId)
if (
status === 'permission' ||
current === undefined ||
(status === 'working' && current === 'monitoring')
) {
result.set(worktreeId, status)
}
}
@@ -254,6 +254,41 @@ describe('resolveWorktreeStatus', () => {
expect(status).toBe('working')
})
it('reports passive monitoring below active work and permission', () => {
const base = {
tabs: [{ id: 'tab-1', title: 'bash' }],
browserTabs: [],
ptyIdsByTabId: livePtyMap('tab-1'),
hasLiveDone: false,
hasRetainedDone: false
}
expect(
resolveWorktreeStatus({
...base,
hasPermission: false,
hasLiveWorking: false,
hasLiveMonitoring: true
})
).toBe('monitoring')
expect(
resolveWorktreeStatus({
...base,
hasPermission: false,
hasLiveWorking: true,
hasLiveMonitoring: true
})
).toBe('working')
expect(
resolveWorktreeStatus({
...base,
hasPermission: true,
hasLiveWorking: true,
hasLiveMonitoring: true
})
).toBe('permission')
})
it('lets heuristic working beat hasLiveDone (newer in-progress signal wins)', () => {
const status = resolveWorktreeStatus({
tabs: [{ id: 'tab-1', title: 'claude [working]' }],
+15 -1
View File
@@ -11,7 +11,13 @@ import type {
import type { TuiAgent } from '../../../shared/tui-agent'
import type { LiveAgentWorktreeStatus } from './worktree-activity-state'
export type WorktreeStatus = 'active' | 'working' | 'permission' | 'done' | 'inactive'
export type WorktreeStatus =
| 'active'
| 'working'
| 'monitoring'
| 'permission'
| 'done'
| 'inactive'
type WorktreeStatusHeuristicOptions = {
liveAgentStatus?: LiveAgentWorktreeStatus
@@ -23,6 +29,7 @@ type WorktreeStatusHeuristicOptions = {
const STATUS_LABELS: Record<WorktreeStatus, string> = {
active: 'Active',
working: 'Working',
monitoring: 'Monitoring background tasks',
permission: 'Needs permission',
done: 'Done',
inactive: 'Inactive'
@@ -48,6 +55,9 @@ export function getWorktreeStatus(
if (options.liveAgentStatus === 'working' || hasStatus('working')) {
return 'working'
}
if (options.liveAgentStatus === 'monitoring') {
return 'monitoring'
}
if (liveTabs.length > 0 || browserTabs.length > 0) {
// Why: browser-only worktrees (no PTY) are still active from the user's point of view.
return 'active'
@@ -132,6 +142,7 @@ export function resolveWorktreeStatus(args: {
terminalLayoutRootsByTabId?: Record<string, TerminalPaneLayoutNode | null | undefined>
hasPermission: boolean
hasLiveWorking: boolean
hasLiveMonitoring?: boolean
hasLiveDone: boolean
hasRetainedDone: boolean
}): WorktreeStatus {
@@ -157,6 +168,9 @@ export function resolveWorktreeStatus(args: {
if (args.hasLiveWorking || heuristic === 'working') {
return 'working'
}
if (args.hasLiveMonitoring || heuristic === 'monitoring') {
return 'monitoring'
}
if (args.hasLiveDone || args.hasRetainedDone) {
return 'done'
}
@@ -18,6 +18,7 @@ function referenceProjection(map: AppState['agentStatusByPaneKey']): string {
paneKey,
entryPaneKey: entry.paneKey,
state: entry.state,
workingMode: entry.workingMode ?? null,
prompt: entry.prompt,
updatedAtBucket: Math.floor(entry.updatedAt / BUCKET_MS),
stateStartedAt: entry.stateStartedAt,
@@ -65,6 +66,7 @@ describe('mobile agent-status projection equivalence', () => {
const shapes: AppState['agentStatusByPaneKey'][] = []
shapes.push({})
shapes.push({ 'tab-0:leaf-0': makeEntry(0) })
shapes.push({ 'tab-0:leaf-0': makeEntry(0, { workingMode: 'monitoring' }) })
const many: AppState['agentStatusByPaneKey'] = {}
for (let index = 0; index < 12; index += 1) {
many[`tab-${index}:leaf-0`] = makeEntry(index)
@@ -620,6 +620,7 @@ function serializeRuntimeMobileAgentStatusEntry(
paneKey,
entryPaneKey: entry.paneKey,
state: entry.state,
workingMode: entry.workingMode ?? null,
prompt: entry.prompt,
updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS),
stateStartedAt: entry.stateStartedAt,
@@ -514,5 +514,61 @@ describe('remote-paired pane: host snapshot mirror vs client byte-derived status
expect(entry?.state).toBe('blocked')
expect(entry?.interactivePrompt).toBe('Allow Bash(rm -rf)?')
})
it('adopts host monitoring metadata without replacing client-owned byte status', () => {
const store = seedPairedClientStore()
applyHostSnapshot(
store,
makeHostSnapshot({ snapshotVersion: 1, hostNow: T0, includeAgentStatus: false }),
T0
)
replayClientOscWorking(store, T0 + 500)
const epochBeforeHostStatus = store.getState().agentStatusEpoch
const sortEpochBeforeHostStatus = store.getState().sortEpoch
applyHostSnapshot(
store,
makeHostSnapshot({
snapshotVersion: 2,
hostNow: T0 + 1_000,
includeAgentStatus: true,
agentStatusOverrides: {
state: 'working',
workingMode: 'monitoring',
prompt: 'host hook prompt'
}
}),
T0 + 1_000
)
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]).toMatchObject({
state: 'working',
workingMode: 'monitoring',
prompt: CLIENT_PROMPT,
updatedAt: T0 + 500
})
expect(store.getState().agentStatusEpoch).toBe(epochBeforeHostStatus + 1)
expect(store.getState().sortEpoch).toBe(sortEpochBeforeHostStatus)
applyHostSnapshot(
store,
makeHostSnapshot({
snapshotVersion: 3,
hostNow: T0 + 2_000,
includeAgentStatus: true,
agentStatusOverrides: { state: 'working' }
}),
T0 + 2_000
)
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]).toMatchObject({
state: 'working',
prompt: CLIENT_PROMPT,
updatedAt: T0 + 500
})
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]?.workingMode).toBeUndefined()
expect(store.getState().agentStatusEpoch).toBe(epochBeforeHostStatus + 2)
expect(store.getState().sortEpoch).toBe(sortEpochBeforeHostStatus)
})
})
})
@@ -1396,6 +1396,9 @@ function buildMirroredAgentStatusPatch(
existing && (clientOwnsEntry || existing.updatedAt > entry.updatedAt)
? {
...normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType),
...(clientOwnsEntry && existing.state === 'working' && entry.state === 'working'
? { workingMode: entry.workingMode }
: {}),
paneKey: entry.paneKey,
worktreeId: entry.worktreeId ?? existing.worktreeId,
tabId: entry.tabId,
@@ -1472,6 +1475,7 @@ function buildMirroredAgentStatusPatch(
existing?.state === 'done' &&
entry.state === 'done' &&
agentEntryCompletionAt(existing) !== agentEntryCompletionAt(entry)
const workingModeChanged = existing?.workingMode !== entry.workingMode
const entrySortRelevantChange =
!existing ||
existing.state !== entry.state ||
@@ -1480,7 +1484,8 @@ function buildMirroredAgentStatusPatch(
entryAttributionChanged ||
doneAttentionChanged ||
isMirroredCommandCodeTurnBump(existing, entry)
aggregateRelevantChange = aggregateRelevantChange || entrySortRelevantChange
aggregateRelevantChange =
aggregateRelevantChange || entrySortRelevantChange || workingModeChanged
sortRelevantChange = sortRelevantChange || entrySortRelevantChange
}
@@ -2132,6 +2137,7 @@ function agentStatusEntryEqual(a: AgentStatusEntry | undefined, b: AgentStatusEn
}
return (
a.state === b.state &&
a.workingMode === b.workingMode &&
a.prompt === b.prompt &&
a.updatedAt === b.updatedAt &&
a.stateStartedAt === b.stateStartedAt &&
@@ -270,6 +270,30 @@ describe('agent status tool + assistant fields', () => {
expect(store.getState().sortEpoch).toBe(firstSortEpoch + 1)
})
it('bumps the status epoch when same-state working enters monitoring', () => {
vi.useFakeTimers()
const store = createTestStore()
store.getState().setAgentStatus('tab-1:1', { state: 'working', prompt: 'p' }, 'claude', {
updatedAt: 1_000,
stateStartedAt: 1_000
})
const firstEpoch = store.getState().agentStatusEpoch
const firstSortEpoch = store.getState().sortEpoch
store
.getState()
.setAgentStatus(
'tab-1:1',
{ state: 'working', workingMode: 'monitoring', prompt: 'p' },
'claude',
{ updatedAt: 2_000, stateStartedAt: 1_000 }
)
expect(store.getState().agentStatusByPaneKey['tab-1:1'].workingMode).toBe('monitoring')
expect(store.getState().agentStatusEpoch).toBe(firstEpoch + 1)
expect(store.getState().sortEpoch).toBe(firstSortEpoch)
})
it('bumps the status epoch, not sort epoch, for same-state done updates', () => {
vi.useFakeTimers()
const store = createTestStore()
@@ -2304,6 +2304,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
matchedSleepingLaunchConfig
const entry: AgentStatusEntry = {
state: payload.state,
workingMode: payload.workingMode,
prompt: payload.prompt,
updatedAt,
stateStartedAt,
@@ -2389,6 +2390,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
existing?.state === 'done' &&
entry.state === 'done' &&
agentEntryCompletionAt(existing) !== agentEntryCompletionAt(entry)
const workingModeChanged = existing?.workingMode !== entry.workingMode
const sortRelevantChange =
!existing ||
existing.state !== payload.state ||
@@ -2414,7 +2416,10 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
entry.providerSession !== existing.providerSession ||
entry.interrupted !== existing.interrupted)
const retentionRelevantChange =
sortRelevantChange || attributionChanged || doneRetentionFieldsChanged
sortRelevantChange ||
attributionChanged ||
workingModeChanged ||
doneRetentionFieldsChanged
// Why: a fresh status means the agent is live again — lift its one-shot retention suppressor.
// Clone the map only when a suppressor exists, else every high-frequency ping churns the ref.
const hasSuppressor = paneKey in s.retentionSuppressedPaneKeys
+35 -19
View File
@@ -22,6 +22,7 @@ import {
normalizeAgentStatusPayload,
type AgentStatusState,
type AgentSubagentSnapshot,
type AgentWorkingMode,
type ParsedAgentStatusPayload
} from './agent-status-types'
import { normalizeOptionalField } from './agent-status-field-normalization'
@@ -2640,21 +2641,31 @@ function updateClaudeRunningNonAgentTask(
}
}
function resolveClaudePaneState(
type ClaudePaneStatusResolution = {
stateName: AgentStatusState
workingMode?: AgentWorkingMode
}
function resolveClaudePaneStatus(
state: HookListenerState,
paneKey: string,
lead: Pick<ClaudeLeadTurnState, 'state' | 'interrupted'>
): AgentStatusState {
): ClaudePaneStatusResolution {
if (lead.state !== 'done') {
return lead.state
return { stateName: lead.state }
}
const roster = state.claudeSubagentRosterByPaneKey.get(paneKey)
return claudeRosterHasWorkingSubagent(roster) ||
(!lead.interrupted &&
(state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) ||
state.claudeActiveSessionCronPaneKeys.has(paneKey)))
? 'working'
: 'done'
if (claudeRosterHasWorkingSubagent(roster)) {
return { stateName: 'working' }
}
if (
!lead.interrupted &&
(state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) ||
state.claudeActiveSessionCronPaneKeys.has(paneKey))
) {
return { stateName: 'working', workingMode: 'monitoring' }
}
return { stateName: 'done' }
}
/** SubagentStart/Stop/TeammateIdle update the roster and re-emit the lead's last known state with the fresh child list, so the sidebar reflects spawn/finish even when a background child outlives the lead turn with no other hook traffic. */
@@ -2851,7 +2862,9 @@ function clearClaudePendingWaitForAgent(
export function clearClaudeAnsweredQuestionWait(
state: HookListenerState,
paneKey: string
): Pick<ClaudeLeadTurnState, 'state' | 'interrupted' | 'turnCompletedAt'> {
): Pick<ClaudeLeadTurnState, 'state' | 'interrupted' | 'turnCompletedAt'> & {
workingMode?: AgentWorkingMode
} {
const lead = state.claudeLeadStateByPaneKey.get(paneKey)
const restored =
lead?.state === 'waiting'
@@ -2865,11 +2878,12 @@ export function clearClaudeAnsweredQuestionWait(
? { lastAssistantMessage: previousTool.lastAssistantMessage }
: {}
)
const effectiveState = resolveClaudePaneState(state, paneKey, restored)
return effectiveState === restored.state
const resolved = resolveClaudePaneStatus(state, paneKey, restored)
return resolved.stateName === restored.state && resolved.workingMode === undefined
? restored
: {
state: effectiveState,
state: resolved.stateName,
...(resolved.workingMode ? { workingMode: resolved.workingMode } : {}),
...(restored.interrupted ? { interrupted: true as const } : {}),
...(restored.turnCompletedAt !== undefined
? { turnCompletedAt: restored.turnCompletedAt }
@@ -2903,7 +2917,7 @@ function buildClaudeCachedLeadStatusPayload(
}
}
return buildClaudeStatusPayload(state, eventName, '', paneKey, hookPayload, {
stateName: resolveClaudePaneState(state, paneKey, {
...resolveClaudePaneStatus(state, paneKey, {
state: leadState,
interrupted: lead?.interrupted
}),
@@ -3075,7 +3089,7 @@ function normalizeClaudeEvent(
const restored = lead.stateBeforeWait ?? { state: 'working' as const }
state.claudeLeadStateByPaneKey.set(paneKey, restored)
return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, {
stateName: resolveClaudePaneState(state, paneKey, restored),
...resolveClaudePaneStatus(state, paneKey, restored),
updateToolSnapshot: true,
interrupted: restored.interrupted,
turnCompletedAt: restored.turnCompletedAt
@@ -3124,7 +3138,7 @@ function normalizeClaudeEvent(
state.claudeActiveSessionCronPaneKeys.delete(paneKey)
}
const effectiveState = resolveClaudePaneState(state, paneKey, {
const resolvedStatus = resolveClaudePaneStatus(state, paneKey, {
state: reportedStateName,
interrupted
})
@@ -3133,7 +3147,7 @@ function normalizeClaudeEvent(
eventAgentId === undefined &&
isTurnBoundary &&
reportedStateName === 'done' &&
effectiveState === 'working' &&
resolvedStatus.stateName === 'working' &&
interrupted !== true
? Date.now()
: undefined
@@ -3151,7 +3165,7 @@ function normalizeClaudeEvent(
if (
isTurnBoundary &&
eventAgentId === undefined &&
effectiveState === 'working' &&
resolvedStatus.stateName === 'working' &&
claudeRosterHasRestoredSnapshotSubagent(effectiveRoster) &&
!claudeRosterHasRuntimeWorkingSubagent(effectiveRoster) &&
!state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) &&
@@ -3162,7 +3176,7 @@ function normalizeClaudeEvent(
}
return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, {
stateName: effectiveState,
...resolvedStatus,
updateToolSnapshot: true,
interrupted,
turnCompletedAt
@@ -3177,6 +3191,7 @@ function buildClaudeStatusPayload(
hookPayload: Record<string, unknown>,
options: {
stateName: AgentStatusState
workingMode?: AgentWorkingMode
updateToolSnapshot: boolean
interrupted?: boolean
sessionBoundary?: boolean
@@ -3194,6 +3209,7 @@ function buildClaudeStatusPayload(
// The normalizer clamps `interrupted` to done payloads, so a gated 'working' emit drops it; claudeLeadStateByPaneKey preserves it for the eventual done.
return normalizeAgentStatusPayload({
state: options.stateName,
workingMode: options.workingMode,
// Why: only lead-origin events may reset the prompt cache; a child-driven refresh must not blank the lead's prompt label.
prompt: resolvePrompt(state, paneKey, promptText, {
resetOnNewTurn: options.updateToolSnapshot && isNewTurnEvent('claude', eventName)
+2
View File
@@ -27,6 +27,7 @@ describe('agent-hook-relay wire shape', () => {
compactTrigger: 'manual',
payload: {
state: 'working',
workingMode: 'monitoring',
prompt: 'roundtrip',
agentType: 'claude'
}
@@ -36,6 +37,7 @@ describe('agent-hook-relay wire shape', () => {
expect(decoded).toEqual(envelope)
expect(decoded.connectionId).toBeNull()
expect(decoded.payload.prompt).toBe('roundtrip')
expect(decoded.payload.workingMode).toBe('monitoring')
})
it('exposes stable JSON-RPC method names', () => {
+56
View File
@@ -0,0 +1,56 @@
// ─── Wire shapes carried from the hook receivers to the renderer over IPC ────
// Why: split out of agent-status-types so that module stays the status vocabulary
// (states, entries, normalization) and the transport envelopes live next to each
// other. Re-exported from agent-status-types, so existing import sites are unchanged.
import type { AgentProviderSessionMetadata } from './agent-session-resume'
import type { WithAgentStatusObservation } from './agent-status-observation'
import type {
AgentStatusOrchestrationContext,
ParsedAgentStatusPayload
} from './agent-status-types'
/** A PTY the pane-key migration could not move, reported for operator triage. */
export type MigrationUnsupportedPtyEntry = {
ptyId: string
worktreeId?: string
tabId?: string
leafId?: string
/** Registry-backed UUID pane proof, when available. */
paneKey?: string
reason: 'legacy-numeric-pane-key'
source: 'local' | 'ssh'
updatedAt: number
}
export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
paneKey: string
launchToken?: string
terminalHandle?: string
tabId?: string
worktreeId?: string
/** Identifies the SSH connection the event arrived on, or null for local.
* Only the remote-ingest path (`ingestRemote`) can stamp it from mux identity; the HTTP path has no mux and always sets null. */
connectionId: string | null
/** Timestamp (ms) when the hook server received this latest status event. */
receivedAt: number
/** Timestamp (ms) when the current state first appeared for this pane. */
stateStartedAt: number
orchestration?: AgentStatusOrchestrationContext
providerSession?: AgentProviderSessionMetadata
/** Resume identity update only; the status-shaped fields are transport placeholders. */
providerSessionOnly?: boolean
/** Live-only Command Code turn boundary key; not persisted to last-status.json. */
promptInteractionKey?: string
/** See AgentStatusEntry.restoredUnconfirmed — hydrated nonterminal provenance. */
restoredUnconfirmed?: boolean
} & WithAgentStatusObservation
/** Wire shape for ordinary pane teardown or a stamped SSH disconnect batch. */
export type AgentStatusClearIpcPayload =
| { paneKey: string }
| {
transient: true
connectionId: string
clearedAt: number
}
+21 -1
View File
@@ -60,6 +60,19 @@ describe('parseAgentStatusPayload', () => {
}
})
it('accepts monitoring only as an optional working discriminator', () => {
expect(parseAgentStatusPayload('{"state":"working","workingMode":"monitoring"}')).toMatchObject(
{ state: 'working', workingMode: 'monitoring' }
)
expect(
parseAgentStatusPayload('{"state":"done","workingMode":"monitoring"}')?.workingMode
).toBeUndefined()
expect(
parseAgentStatusPayload('{"state":"working","workingMode":"unknown"}')?.workingMode
).toBeUndefined()
expect(parseAgentStatusPayload('{"state":"working"}')?.workingMode).toBeUndefined()
})
it('returns null for invalid state', () => {
expect(parseAgentStatusPayload('{"state":"running"}')).toBeNull()
expect(parseAgentStatusPayload('{"state":"idle"}')).toBeNull()
@@ -586,7 +599,14 @@ describe('agentSubagentsEqual', () => {
// they used to take, including where stringify would have altered the payload.
describe('normalizeAgentStatusPayload matches the JSON round trip', () => {
const CASES: Record<string, unknown>[] = [
{ state: 'working', prompt: 'p', agentType: 'grok', toolName: 'sh', toolInput: 'ls' },
{
state: 'working',
workingMode: 'monitoring',
prompt: 'p',
agentType: 'grok',
toolName: 'sh',
toolInput: 'ls'
},
{ state: 'done', prompt: '', agentType: 'devin', interrupted: true },
// stringify DROPS undefined-valued keys; the direct path passes them through
{
+12 -44
View File
@@ -14,9 +14,15 @@ import {
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
export { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization'
export type {
AgentStatusClearIpcPayload,
AgentStatusIpcPayload,
MigrationUnsupportedPtyEntry
} from './agent-status-ipc-payload'
export const AGENT_STATUS_STATES = ['working', 'blocked', 'waiting', 'done'] as const
export type AgentStatusState = (typeof AGENT_STATUS_STATES)[number]
export type AgentWorkingMode = 'monitoring'
// Why: agent types aren't a fixed set (custom agents exist); any non-empty string is
// accepted — these well-known names are just a convenience union for pattern-matching.
export type WellKnownAgentType =
@@ -93,6 +99,8 @@ export type AgentSubagentSnapshot = {
export type AgentStatusEntry = {
state: AgentStatusState
/** Ongoing work that does not require foreground agent execution. Only valid while working. */
workingMode?: AgentWorkingMode
/** The user's most recent prompt. Cached across the turn — later tool-use events
* omit it, so the last value persists until a new prompt or pane reset. Empty when unknown. */
prompt: string
@@ -154,23 +162,13 @@ export type AgentStatusEntry = {
restoredUnconfirmed?: boolean
} & WithAgentStatusObservation
export type MigrationUnsupportedPtyEntry = {
ptyId: string
worktreeId?: string
tabId?: string
leafId?: string
/** Registry-backed UUID pane proof, when available. */
paneKey?: string
reason: 'legacy-numeric-pane-key'
source: 'local' | 'ssh'
updatedAt: number
}
// ─── Agent status payload shape (what hook receivers send via IPC) ──────────
// Hook integrations provide only normalized state fields; the renderer fills the rest (updatedAt, paneKey, …) on IPC receipt.
export type AgentStatusPayload = {
state: AgentStatusState
/** Ongoing work that does not require foreground agent execution. Only valid while working. */
workingMode?: AgentWorkingMode
prompt?: string
agentType?: AgentType
model?: string
@@ -212,6 +210,7 @@ export function pickParsedAgentStatusPayload(
): ParsedAgentStatusPayload {
return {
state: row.state,
...(row.workingMode !== undefined ? { workingMode: row.workingMode } : {}),
prompt: row.prompt,
...(row.agentType !== undefined ? { agentType: row.agentType } : {}),
...(row.model !== undefined ? { model: row.model } : {}),
@@ -232,38 +231,6 @@ export function pickParsedAgentStatusPayload(
* Wire shape for agent-status IPC. Both `agentStatus:set` and `agentStatus:getSnapshot`
* produce this shape so renderer call sites share a single `setAgentStatus` path.
*/
export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
paneKey: string
launchToken?: string
terminalHandle?: string
tabId?: string
worktreeId?: string
/** Identifies the SSH connection the event arrived on, or null for local.
* Only the remote-ingest path (`ingestRemote`) can stamp it from mux identity; the HTTP path has no mux and always sets null. */
connectionId: string | null
/** Timestamp (ms) when the hook server received this latest status event. */
receivedAt: number
/** Timestamp (ms) when the current state first appeared for this pane. */
stateStartedAt: number
orchestration?: AgentStatusOrchestrationContext
providerSession?: AgentProviderSessionMetadata
/** Resume identity update only; the status-shaped fields are transport placeholders. */
providerSessionOnly?: boolean
/** Live-only Command Code turn boundary key; not persisted to last-status.json. */
promptInteractionKey?: string
/** See AgentStatusEntry.restoredUnconfirmed — hydrated nonterminal provenance. */
restoredUnconfirmed?: boolean
} & WithAgentStatusObservation
/** Wire shape for ordinary pane teardown or a stamped SSH disconnect batch. */
export type AgentStatusClearIpcPayload =
| { paneKey: string }
| {
transient: true
connectionId: string
clearedAt: number
}
/** Maximum character length for the toolName field. */
export const AGENT_STATUS_TOOL_NAME_MAX_LENGTH = 60
/** Maximum character length for the toolInput preview. */
@@ -407,6 +374,7 @@ function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload |
}
return {
state: state as AgentStatusState,
workingMode: state === 'working' && obj.workingMode === 'monitoring' ? 'monitoring' : undefined,
prompt: normalizePromptField(obj.prompt),
// Why: normalize like the other single-line fields so embedded newlines (e.g. `agentType: "claude\nrogue"`) can't break single-line UI and equality checks.
agentType: normalizeOptionalField(obj.agentType, AGENT_TYPE_MAX_LENGTH),
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { terminalStatusPayloadMatchesHook } from './agent-terminal-status-equivalence'
describe('terminalStatusPayloadMatchesHook', () => {
const hook = {
state: 'working' as const,
workingMode: 'monitoring' as const,
prompt: 'watch tests',
agentType: 'claude'
}
it('accepts an otherwise equivalent terminal payload that omits hook-owned mode', () => {
expect(
terminalStatusPayloadMatchesHook(hook, {
state: 'working',
prompt: 'watch tests',
agentType: 'claude'
})
).toBe(true)
})
it('rejects a terminal payload from a different turn', () => {
expect(
terminalStatusPayloadMatchesHook(hook, {
state: 'working',
prompt: 'fix tests',
agentType: 'claude'
})
).toBe(false)
})
})
@@ -0,0 +1,32 @@
import type { ParsedAgentStatusPayload } from './agent-status-types'
/** Whether a terminal status is the same hook status with only hook-owned metadata omitted.
*
* `preserveActiveTurnStamp` marks the cached hook payload as the still-open turn the OSC ping
* belongs to: OSC carries neither the turn stamp nor the assistant tail, so their absence must
* read as "omitted", not "changed", or the ping overwrites a live turn's stamp. */
export function terminalStatusPayloadMatchesHook(
cachedHookPayload: ParsedAgentStatusPayload,
terminalPayload: ParsedAgentStatusPayload,
preserveActiveTurnStamp = false
): boolean {
// OSC never carries model/children; compare only fields its protocol owns.
return (
cachedHookPayload.state === terminalPayload.state &&
// OSC never carries workingMode either; only a stated mismatch breaks equivalence.
(terminalPayload.workingMode === undefined ||
cachedHookPayload.workingMode === terminalPayload.workingMode) &&
cachedHookPayload.prompt === terminalPayload.prompt &&
cachedHookPayload.agentType === terminalPayload.agentType &&
cachedHookPayload.toolName === terminalPayload.toolName &&
cachedHookPayload.toolInput === terminalPayload.toolInput &&
cachedHookPayload.interactivePrompt === terminalPayload.interactivePrompt &&
(cachedHookPayload.lastAssistantMessage === terminalPayload.lastAssistantMessage ||
(preserveActiveTurnStamp && terminalPayload.lastAssistantMessage === undefined)) &&
cachedHookPayload.interrupted === terminalPayload.interrupted &&
// A session-boundary done must never dedupe against a real done (STA-3386).
cachedHookPayload.sessionBoundary === terminalPayload.sessionBoundary &&
(cachedHookPayload.turnCompletedAt === terminalPayload.turnCompletedAt ||
(preserveActiveTurnStamp && terminalPayload.turnCompletedAt === undefined))
)
}
@@ -112,28 +112,28 @@ describe('Claude background task status', () => {
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'UserPromptSubmit',
prompt: 'run a background sleep'
})?.state
).toBe('working')
})
).toMatchObject({ state: 'working', workingMode: undefined })
expect(
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'Stop',
background_tasks: [RUNNING_SHELL]
})?.state
).toBe('working')
})
).toMatchObject({ state: 'working', workingMode: 'monitoring' })
expect(
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'SubagentStop',
agent_id: 'a70fdf2986e38302b',
background_tasks: [RUNNING_SHELL]
})?.state
).toBe('working')
})
).toMatchObject({ state: 'working', workingMode: 'monitoring' })
expect(
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'UserPromptSubmit',
prompt: '<task-notification><status>completed</status></task-notification>'
})?.state
).toBe('working')
})
).toMatchObject({ state: 'working', workingMode: undefined })
expect(
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'Stop',
@@ -142,6 +142,27 @@ describe('Claude background task status', () => {
).toBe('done')
})
it('keeps foreground child work active before falling back to monitoring', () => {
const state = createHookListenerState()
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'SubagentStart',
agent_id: 'child-1'
})
expect(
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'Stop',
background_tasks: [{ id: 'child-1', type: 'subagent', status: 'running' }, RUNNING_SHELL]
})
).toMatchObject({ state: 'working', workingMode: undefined })
expect(
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'SubagentStop',
agent_id: 'child-1'
})
).toMatchObject({ state: 'working', workingMode: 'monitoring' })
})
it('keeps pending task state when pane authority moves', () => {
const state = createHookListenerState()
claudeEvent(state, SOURCE_PANE, {
@@ -209,15 +230,15 @@ describe('Claude background task status', () => {
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'Stop',
session_crons: [{ id: 'cron-1' }]
})?.state
).toBe('working')
})
).toMatchObject({ state: 'working', workingMode: 'monitoring' })
expect(state.claudeActiveSessionCronPaneKeys.has(SOURCE_PANE)).toBe(true)
expect(
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'SubagentStop',
agent_id: 'child-1'
})?.state
).toBe('working')
})
).toMatchObject({ state: 'working', workingMode: 'monitoring' })
expect(
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'Stop',
+9 -3
View File
@@ -1,4 +1,4 @@
import type { AgentType } from './agent-status-types'
import type { AgentType, AgentWorkingMode } from './agent-status-types'
import type { ExecutionHostId } from './execution-host'
import type { RepoIcon } from './repo-icon'
import type { TuiAgent } from './tui-agent'
@@ -35,11 +35,15 @@ export const DASHBOARD_MAX_MAP_WORKSPACES = 2_000
/** Kept distinct from `bucket` so attention cards retain their precise dot state. */
export type DashboardCardDotState = 'working' | 'blocked' | 'waiting' | 'done' | 'idle'
export type DashboardCardDisplayState = DashboardCardDotState | 'monitoring'
/** Completed agents stay green until acknowledged, then settle into gray idle. */
export function dashboardCardDisplayState(
card: Pick<DashboardCard, 'dotState' | 'unseen'>
): DashboardCardDotState {
card: Pick<DashboardCard, 'dotState' | 'workingMode' | 'unseen'>
): DashboardCardDisplayState {
if (card.dotState === 'working' && card.workingMode === 'monitoring') {
return 'monitoring'
}
return card.dotState === 'done' && !card.unseen ? 'idle' : card.dotState
}
@@ -84,6 +88,8 @@ export type DashboardCard = {
agentType: AgentType
bucket: DashboardBucket
dotState: DashboardCardDotState
/** Additive discriminator; older pop-outs render this as ordinary working. */
workingMode?: AgentWorkingMode
/** One-line task/prompt text shown on the card. */
task: string
/** The most recent message the user sent this agent (its current prompt). */
+4
View File
@@ -3,6 +3,7 @@ import type {
AgentStatusEntry,
AgentStatusOrchestrationContext,
AgentStatusState,
AgentWorkingMode,
AgentType
} from './agent-status-types'
import type {
@@ -812,6 +813,7 @@ export type RuntimeWorktreeAgentRow = {
/** paneKey of the orchestration parent, or null for a root agent. */
parentPaneKey: string | null
state: AgentStatusState
workingMode?: AgentWorkingMode
agentType: AgentType | null
/** Raw hook-reported prompt. Display surfaces can prefer displayName. */
prompt: string
@@ -871,6 +873,8 @@ export type RuntimeWorktreePsSummary = {
lastOutputAt: number | null
preview: string
status: RuntimeWorktreeStatus
/** Optional discriminator for a working workspace; older clients fall back to ordinary working. */
workingMode?: AgentWorkingMode
/** Live agents in this worktree, newest-state-first. Empty for shell-only
* worktrees. Mirrors desktop's inline agent list (WorktreeCardAgents). */
agents: RuntimeWorktreeAgentRow[]