feat(agents): render Claude monitoring as a static turquoise dot

Replaces the yellow Radio glyph from #14205 with a static dot in a new
--agent-monitoring token (#8abeb7), defined once for light and once for
dark like --workspace-status-done, so the status keeps its identity when
the theme flips. Deliberately a fixed UI value: it never reads terminal
theme state at runtime.

Adds the turn-boundary notification pins. The monitoring predicate and
the turnCompletedAt stamp are computed from the same "lead said done but
the pane resolves to working" expression, so a rename can silently drop
the stamp and kill a completion notification that works today with
nothing else going red.
This commit is contained in:
Brennan Benson
2026-08-23 18:58:25 -07:00
parent 835dfc0eb1
commit 76f77bf811
17 changed files with 342 additions and 8926 deletions
+4 -2
View File
@@ -1,5 +1,4 @@
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'
@@ -18,6 +17,7 @@ const STATUS_COLORS: Record<WorktreeStatus, string> = {
permission: '#ef4444',
inactive: 'rgba(115,115,115,0.4)'
}
const MONITORING_COLOR = '#8abeb7'
export function AgentSpinner({
status,
@@ -48,9 +48,11 @@ export function AgentSpinner({
const color = STATUS_COLORS[status] ?? STATUS_COLORS.inactive
if (monitoring) {
// Why: static dot, not a spinner — the lead turn is over. Keep MONITORING_COLOR
// in step with --agent-monitoring in the desktop main.css.
return (
<View style={styles.wrapper} accessibilityLabel="Monitoring background tasks">
<Radio size={12} color={STATUS_COLORS.working} />
<View style={[styles.dot, { backgroundColor: MONITORING_COLOR }]} />
</View>
)
}
+7 -11
View File
@@ -1,5 +1,4 @@
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'
@@ -8,8 +7,10 @@ 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' | 'monitoring'>, string> = {
const DOT_COLORS: Record<Exclude<AgentDotState, 'working'>, string> = {
done: '#10b981',
// Keep in step with --agent-monitoring in the desktop main.css.
monitoring: '#8abeb7',
blocked: '#ef4444',
waiting: '#ef4444',
interrupted: '#ef4444',
@@ -46,16 +47,11 @@ 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.wrapper}
accessibilityLabel={state === 'monitoring' ? 'Monitoring background tasks' : undefined}
>
<View style={[styles.dot, { backgroundColor: DOT_COLORS[state] }]} />
</View>
)
@@ -4,22 +4,38 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentSpinner } from './AgentSpinner'
import { AgentStateDot } from './AgentStateDot'
const DESKTOP_WORKING_COLOR = '#eab308'
// Why: pinned to --agent-monitoring in the desktop main.css. Mobile has no CSS
// tokens, so this literal is the only thing keeping the two surfaces in step.
const DESKTOP_MONITORING_COLOR = '#8abeb7'
const DESKTOP_DONE_COLOR = '#10b981'
type MonitoringTestRenderer = {
readonly root: {
findByType(type: string): { props: Record<string, unknown> }
findAllByType(type: string): { props: Record<string, unknown> }[]
}
unmount(): void
}
/** Background colour of the inner dot (the wrapper View is index 0). */
function dotColor(renderer: MonitoringTestRenderer | null): string | undefined {
const inner = renderer?.root.findAllByType('View')[1]
const style = inner?.props.style
const layers = Array.isArray(style) ? style : [style]
for (const layer of layers) {
const color = (layer as { backgroundColor?: string } | undefined)?.backgroundColor
if (typeof color === 'string') {
return color
}
}
return undefined
}
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() {
@@ -48,30 +64,33 @@ describe('mobile monitoring indicators', () => {
renderer = null
})
it('renders a static Radio for a monitoring agent', async () => {
it('renders a static turquoise dot 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(dotColor(renderer)).toBe(DESKTOP_MONITORING_COLOR)
// Why: the lead turn is over — spinning here is the bug this state exists to fix.
expect(animationTiming).not.toHaveBeenCalled()
expect(animationLoop).not.toHaveBeenCalled()
})
it('renders a static Radio for an all-monitoring workspace', async () => {
it('keeps a monitoring agent dot separable from the done dot', async () => {
await act(async () => {
renderer = create(createElement(AgentStateDot, { state: 'monitoring' }))
})
expect(dotColor(renderer)).not.toBe(DESKTOP_DONE_COLOR)
})
it('renders a static turquoise dot 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(dotColor(renderer)).toBe(DESKTOP_MONITORING_COLOR)
expect(animationTiming).not.toHaveBeenCalled()
expect(animationLoop).not.toHaveBeenCalled()
})
@@ -27,6 +27,52 @@ afterEach(() => {
})
describe('AgentHookServer ingestTerminalStatus', () => {
it('keeps hook monitoring mode across an equivalent OSC ping until a hook clears it', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
source: 'claude',
hookEventName: 'Stop',
payload: {
state: 'working',
workingMode: 'monitoring',
prompt: 'watch the build',
agentType: 'claude'
}
},
'conn-1'
)
server.ingestTerminalStatus({
paneKey: PANE,
connectionId: 'conn-1',
payload: { state: 'working', prompt: 'watch the build', agentType: 'claude' }
})
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
workingMode: 'monitoring'
})
server.ingestRemote(
{
paneKey: PANE,
source: 'claude',
hookEventName: 'PreToolUse',
payload: {
state: 'working',
prompt: 'watch the build',
agentType: 'claude',
toolName: 'Read'
}
},
'conn-1'
)
expect(server.getStatusSnapshot()[0]?.workingMode).toBeUndefined()
})
it('preserves a hook turn stamp when an OSC repaint omits hook-only completion text', () => {
const server = new AgentHookServer()
const listener = vi.fn()
@@ -228,6 +228,7 @@ describe('AgentHookServer listener replay', () => {
const baseline = server.getStatusSnapshot()[0]
expect(baseline).not.toHaveProperty('claudeRunningNonAgentTask')
expect(baseline).toMatchObject({ state: 'working', workingMode: 'monitoring' })
expect(
server.inferInterrupt({
paneKey: PANE,
@@ -77,7 +77,11 @@ describe('Last-status persistence', () => {
worktreeId: 'wt-1',
receivedAt: expect.any(Number),
stateStartedAt: expect.any(Number),
payload: expect.objectContaining({ state: 'working', prompt: 'persist me' })
payload: expect.objectContaining({
state: 'working',
workingMode: 'monitoring',
prompt: 'persist me'
})
})
expect(file.entries[PANE].launchToken).toBeUndefined()
expect(file.entries[PANE].launchTokenHash).toBe(
File diff suppressed because it is too large Load Diff
+9
View File
@@ -62,6 +62,7 @@
--color-ring: var(--ring);
--color-agent-question: var(--agent-question);
--color-agent-question-text: var(--agent-question-text);
--color-agent-monitoring: var(--agent-monitoring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
@@ -181,6 +182,13 @@
--agent-question: var(--color-orange-600);
/* Legible weight for text/glyphs on a tinted --agent-question surface. */
--agent-question-text: var(--color-orange-700);
/* "The turn is over; background work is still registered." Turquoise, not a
green or a yellow: it has to stay separable from the emerald done/active dot
at dot size, and from working-yellow on the map. Same literal in both themes,
like --workspace-status-done — a status keeps its identity when the theme
flips. Deliberately NOT read from the terminal theme at runtime: this is a
fixed UI value like every other status colour. */
--agent-monitoring: #8abeb7;
--status-success: #15803d;
--status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent);
--status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent);
@@ -291,6 +299,7 @@
--ai-action-accent: var(--color-violet-400);
--agent-question: var(--color-orange-500);
--agent-question-text: var(--color-orange-300);
--agent-monitoring: #8abeb7;
--status-success: #86efac;
--status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent);
--status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent);
@@ -42,13 +42,15 @@ describe('AgentStateDot', () => {
expect(markup).toContain('motion-reduce:border-t-yellow-500')
})
it('renders monitoring as a static yellow radio glyph', () => {
it('renders monitoring as a static turquoise dot, never a spinner', () => {
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).toContain('bg-agent-monitoring')
// Why: the turn is over — a spinner here is the bug this state exists to fix.
expect(markup).not.toContain('data-agent-spinner')
// Why: must stay visually separable from the done/active dot.
expect(markup).not.toContain('bg-emerald-500')
})
it('renders done as an emerald check icon', () => {
+6 -13
View File
@@ -1,5 +1,5 @@
import React from 'react'
import { CircleCheck, Radio } from 'lucide-react'
import { CircleCheck } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
@@ -84,17 +84,6 @@ 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
@@ -132,7 +121,11 @@ export const AgentStateDot = React.memo(function AgentStateDot({
inner,
state === 'blocked' || state === 'interrupted' || state === 'failed'
? 'bg-red-500'
: 'bg-neutral-500/40'
: // Why: static dot, not a spinner — the turn is over. Turquoise stays
// separable from the emerald done dot at this size.
state === 'monitoring'
? 'bg-agent-monitoring'
: 'bg-neutral-500/40'
)}
/>
</span>
@@ -136,7 +136,7 @@ describe('palette live status', () => {
await render()
expect(testContainer.querySelector('[data-spinner]')).toBeNull()
expect(testContainer.querySelector('.lucide-radio')?.classList).toContain('text-yellow-500')
expect(testContainer.querySelector('.bg-agent-monitoring')).not.toBeNull()
expect(dotLabels()).toEqual(['Monitoring background tasks'])
})
@@ -276,7 +276,7 @@ describe('palette live status', () => {
})
expect(testContainer.querySelector('[data-spinner]')).toBeNull()
expect(testContainer.querySelector('.lucide-radio')?.classList).toContain('text-yellow-500')
expect(testContainer.querySelector('.bg-agent-monitoring')).not.toBeNull()
expect(dotLabels()).toEqual(['Monitoring background tasks'])
expect(testContainer.querySelector('[title="Monitoring background tasks"]')).not.toBeNull()
})
@@ -420,8 +420,10 @@
stroke: var(--color-yellow-500);
}
/* Why: static turquoise, distinct from working-yellow — the turn is over but
background work is still registered. Same token as the sidebar/dashboard dot. */
.fleet-status-monitoring .agent-map-agent-mark {
stroke: var(--color-yellow-500);
stroke: var(--agent-monitoring);
}
.fleet-status-blocked .agent-map-agent-mark {
@@ -362,8 +362,7 @@ describe('DashboardAgentRow', () => {
)
expect(markup).toContain('Monitoring background tasks')
expect(markup).toContain('lucide-radio')
expect(classTokens(markup)).toContain('text-yellow-500')
expect(classTokens(markup)).toContain('bg-agent-monitoring')
expect(markup).not.toContain('data-agent-spinner')
expect(markup).not.toContain('data-agent-row-tool-slot')
})
@@ -31,13 +31,13 @@ describe('StatusIndicator', () => {
expect(markup).toContain('motion-reduce:border-t-yellow-500')
})
it('renders monitoring as a static radio glyph', () => {
it('renders monitoring as a static turquoise dot, never a spinner', () => {
const markup = renderMarkup('monitoring')
expect(markup).toContain('title="Monitoring background tasks"')
expect(markup).toContain('lucide-radio')
expect(markup).toContain('text-yellow-500')
expect(markup).toContain('bg-agent-monitoring')
expect(markup).not.toContain('data-agent-spinner')
expect(markup).not.toContain('bg-emerald-500')
})
it('renders permission as the shared question glyph', () => {
@@ -1,5 +1,4 @@
import React from 'react'
import { Radio } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
@@ -41,18 +40,6 @@ 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
@@ -79,7 +66,11 @@ const StatusIndicator = React.memo(function StatusIndicator({
// 'active' (terminal open, quiet). Working uses a yellow
// ring above; 'inactive' stays grey.
'bg-emerald-500'
: 'bg-neutral-500/40'
: // Why: static turquoise — the lead turn ended, but background work
// is still registered, so this is neither working nor done.
status === 'monitoring'
? 'bg-agent-monitoring'
: 'bg-neutral-500/40'
)}
/>
</span>
@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from 'vitest'
import { createAgentCompletionCoordinator } from './agent-completion-coordinator'
import { useAgentCompletionCoordinatorLifecycle } from './agent-completion-coordinator-test-harness'
import {
createHookListenerState,
normalizeHookPayload
} from '../../../../shared/agent-hook-listener'
import { makePaneKey } from '../../../../shared/stable-pane-id'
// Why: STA-4119's second complaint is the missing completion notification. This drives the REAL
// hook listener and feeds its real output into the REAL coordinator, rather than hand-writing a
// payload — the whole question is whether the two layers actually agree about a monitoring turn.
const PANE = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
const RUNNING_SHELL = {
id: 'shell-1',
type: 'shell',
status: 'running',
description: 'Run the dev server',
command: 'pnpm dev'
}
function hookPayload(
state: ReturnType<typeof createHookListenerState>,
payload: Record<string, unknown>
) {
const parsed = normalizeHookPayload(
state,
'claude',
{ paneKey: PANE, payload },
'production'
)?.payload
if (!parsed) {
throw new Error('listener produced no payload')
}
// The hook server stamps stateStartedAt on the way to the renderer.
return { ...parsed, stateStartedAt: 1_700_000_000_000 }
}
function createCoordinator() {
const dispatchCompletion = vi.fn()
const dispatchHookLifecycle = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: PANE,
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
dispatchHookLifecycle,
isLive: () => true
})
return { coordinator, dispatchCompletion, dispatchHookLifecycle }
}
/** Meta the coordinator hands the notification dispatcher. */
function completionMeta(dispatchCompletion: ReturnType<typeof vi.fn>) {
return dispatchCompletion.mock.calls[0]?.[1] as
| { source?: string; agentStatus?: { state?: string; workingMode?: string } }
| undefined
}
describe('completion notification when a lead turn ends into monitoring', () => {
useAgentCompletionCoordinatorLifecycle()
it('announces completion when the turn ends but the pane stays working for a background shell', () => {
const listener = createHookListenerState()
const { coordinator, dispatchCompletion, dispatchHookLifecycle } = createCoordinator()
coordinator.observeHookStatus(
hookPayload(listener, { hook_event_name: 'UserPromptSubmit', prompt: 'start the dev server' })
)
const monitoring = hookPayload(listener, {
hook_event_name: 'Stop',
background_tasks: [RUNNING_SHELL]
})
// Precondition: the pane really is in the monitoring state, not done.
expect(monitoring).toMatchObject({ state: 'working', workingMode: 'monitoring' })
expect(typeof monitoring.turnCompletedAt).toBe('number')
coordinator.observeHookStatus(monitoring)
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
const meta = completionMeta(dispatchCompletion)
expect(meta?.source).toBe('hook')
// Why: the announcement is a synthesized `done` while the reported row stays `working` —
// the notification follows the TURN boundary, not the rendered state.
expect(meta?.agentStatus?.state).toBe('done')
// Why: announce only. Running pane lifecycle here would settle a pane that is still working.
expect(dispatchHookLifecycle).not.toHaveBeenCalledWith(
expect.objectContaining({ state: 'done' })
)
})
it('does not announce twice when the background shell later clears to done', () => {
const listener = createHookListenerState()
const { coordinator, dispatchCompletion } = createCoordinator()
coordinator.observeHookStatus(
hookPayload(listener, { hook_event_name: 'UserPromptSubmit', prompt: 'start the dev server' })
)
coordinator.observeHookStatus(
hookPayload(listener, { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] })
)
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
// The shell exits; the same turn's all-clear must not re-announce.
coordinator.observeHookStatus(
hookPayload(listener, {
hook_event_name: 'PostToolUse',
tool_name: 'KillShell',
background_tasks: []
})
)
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
})
it('does not announce mid-turn while the agent is still working in the foreground', () => {
const listener = createHookListenerState()
const { coordinator, dispatchCompletion } = createCoordinator()
coordinator.observeHookStatus(
hookPayload(listener, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' })
)
coordinator.observeHookStatus(
hookPayload(listener, { hook_event_name: 'PreToolUse', tool_name: 'Bash' })
)
expect(dispatchCompletion).not.toHaveBeenCalled()
})
})
@@ -145,6 +145,88 @@ describe('Claude background task status', () => {
expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(false)
})
// Why: STA-4119's second complaint is the missing completion notification. The renderer's
// completion coordinator announces off `turnCompletedAt` on a still-`working` payload, so the
// stamp — not the rendered state — is what makes the notification fire when a turn ends into
// monitoring. The stamp predicate and the monitoring predicate are computed from the same
// "lead said done but the pane resolves to working" expression, so a refactor that renames one
// can silently drop the other with nothing else going red. These pin both halves.
it('stamps the turn end when a finished lead falls back to monitoring', () => {
const state = createHookListenerState()
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'UserPromptSubmit',
prompt: 'start the dev server'
})
const monitoring = claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'Stop',
background_tasks: [RUNNING_SHELL]
})
expect(monitoring).toMatchObject({ state: 'working', workingMode: 'monitoring' })
expect(typeof monitoring?.turnCompletedAt).toBe('number')
})
it('stamps a lead that ends into a working subagent, which carries no monitoring mode', () => {
const state = createHookListenerState()
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' })
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'SubagentStart', agent_id: 'child-1' })
const childWorking = claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'Stop',
background_tasks: [{ id: 'child-1', type: 'subagent', status: 'running' }]
})
// Why: real agent work keeps the spinner (no workingMode) but the lead turn still ended,
// so the stamp must be present. The two predicates are deliberately not the same.
expect(childWorking).toMatchObject({ state: 'working', workingMode: undefined })
expect(typeof childWorking?.turnCompletedAt).toBe('number')
})
it('does not stamp a turn that is still running in the foreground', () => {
const state = createHookListenerState()
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'work' })
const midTurn = claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'PreToolUse',
tool_name: 'Bash'
})
expect(midTurn).toMatchObject({ state: 'working', workingMode: undefined })
expect(midTurn?.turnCompletedAt).toBeUndefined()
})
it('does not stamp an interrupted lead, which settles done instead of monitoring', () => {
const state = createHookListenerState()
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'start it' })
markClaudeLeadTurnInterrupted(state, SOURCE_PANE)
const interrupted = claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'Stop',
background_tasks: [RUNNING_SHELL]
})
expect(interrupted?.state).toBe('done')
expect(interrupted?.workingMode).toBeUndefined()
expect(interrupted?.turnCompletedAt).toBeUndefined()
})
it('does not stamp a turn that simply finished with nothing left running', () => {
const state = createHookListenerState()
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'quick job' })
const finished = claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'Stop',
background_tasks: [],
session_crons: []
})
// Why: the stamp exists to announce a turn whose pane STAYS working. A plain done
// needs no stamp — the ordinary done path already notifies.
expect(finished?.state).toBe('done')
expect(finished?.turnCompletedAt).toBeUndefined()
})
it('keeps foreground child work active before falling back to monitoring', () => {
const state = createHookListenerState()
claudeEvent(state, SOURCE_PANE, {