mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
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:
@@ -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],
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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) &&
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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]' }],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user