revert(agents): restore the yellow Radio glyph for monitoring

Brennan chose #14205's original treatment over the turquoise dot, so the visual
goes back to nwparker's: lucide Radio in text-yellow-500 across the sidebar,
dashboard dot, cmd-j palette and agent-map ring.

Reverts only the visual surface. The turn-boundary notification pins stay — the
monitoring predicate and the turnCompletedAt stamp share an expression, so a
rename can silently drop the stamp and kill a completion that works today with
nothing else going red. The --agent-monitoring token is removed with its last
consumer rather than left dead in main.css.
This commit is contained in:
Brennan Benson
2026-08-24 11:36:48 -07:00
parent 76f77bf811
commit e407944ff2
11 changed files with 64 additions and 77 deletions
+2 -4
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
import { Radio } from 'lucide-react-native'
import { Animated, Easing, StyleSheet, View } from 'react-native'
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
@@ -17,7 +18,6 @@ const STATUS_COLORS: Record<WorktreeStatus, string> = {
permission: '#ef4444',
inactive: 'rgba(115,115,115,0.4)'
}
const MONITORING_COLOR = '#8abeb7'
export function AgentSpinner({
status,
@@ -48,11 +48,9 @@ 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">
<View style={[styles.dot, { backgroundColor: MONITORING_COLOR }]} />
<Radio size={12} color={STATUS_COLORS.working} />
</View>
)
}
+11 -7
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
import { Radio } from 'lucide-react-native'
import { Animated, Easing, StyleSheet, View } from 'react-native'
import type { AgentDotState } from '../worktree/agent-row-display'
@@ -7,10 +8,8 @@ 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',
// Keep in step with --agent-monitoring in the desktop main.css.
monitoring: '#8abeb7',
blocked: '#ef4444',
waiting: '#ef4444',
interrupted: '#ef4444',
@@ -47,11 +46,16 @@ 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}
accessibilityLabel={state === 'monitoring' ? 'Monitoring background tasks' : undefined}
>
<View style={styles.wrapper}>
<View style={[styles.dot, { backgroundColor: DOT_COLORS[state] }]} />
</View>
)
@@ -4,38 +4,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentSpinner } from './AgentSpinner'
import { AgentStateDot } from './AgentStateDot'
// 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'
const DESKTOP_WORKING_COLOR = '#eab308'
type MonitoringTestRenderer = {
readonly root: {
findAllByType(type: string): { props: Record<string, unknown> }[]
findByType(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() {
@@ -64,33 +48,30 @@ describe('mobile monitoring indicators', () => {
renderer = null
})
it('renders a static turquoise dot for a monitoring agent', async () => {
it('renders a static Radio for a monitoring agent', async () => {
await act(async () => {
renderer = create(createElement(AgentStateDot, { state: 'monitoring' }))
})
expect(dotColor(renderer)).toBe(DESKTOP_MONITORING_COLOR)
// Why: the lead turn is over — spinning here is the bug this state exists to fix.
expect(renderer?.root.findByType('Radio').props).toMatchObject({
color: DESKTOP_WORKING_COLOR,
size: 10
})
expect(animationTiming).not.toHaveBeenCalled()
expect(animationLoop).not.toHaveBeenCalled()
})
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 () => {
it('renders a static Radio for an all-monitoring workspace', async () => {
await act(async () => {
renderer = create(
createElement(AgentSpinner, { status: 'working', workingMode: 'monitoring' })
)
})
expect(dotColor(renderer)).toBe(DESKTOP_MONITORING_COLOR)
expect(renderer?.root.findByType('Radio').props).toMatchObject({
color: DESKTOP_WORKING_COLOR,
size: 12
})
expect(animationTiming).not.toHaveBeenCalled()
expect(animationLoop).not.toHaveBeenCalled()
})
-9
View File
@@ -62,7 +62,6 @@
--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);
@@ -182,13 +181,6 @@
--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);
@@ -299,7 +291,6 @@
--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,15 +42,13 @@ describe('AgentStateDot', () => {
expect(markup).toContain('motion-reduce:border-t-yellow-500')
})
it('renders monitoring as a static turquoise dot, never a spinner', () => {
it('renders monitoring as a static yellow radio glyph', () => {
const markup = renderMarkup('monitoring')
expect(markup).toContain('aria-label="Monitoring background tasks"')
expect(markup).toContain('bg-agent-monitoring')
// Why: the turn is over — a spinner here is the bug this state exists to fix.
expect(markup).toContain('lucide-radio')
expect(markup).toContain('text-yellow-500')
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', () => {
+13 -6
View File
@@ -1,5 +1,5 @@
import React from 'react'
import { CircleCheck } from 'lucide-react'
import { CircleCheck, Radio } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
@@ -84,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
@@ -121,11 +132,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({
inner,
state === 'blocked' || state === 'interrupted' || state === 'failed'
? 'bg-red-500'
: // 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'
: 'bg-neutral-500/40'
)}
/>
</span>
@@ -136,7 +136,7 @@ describe('palette live status', () => {
await render()
expect(testContainer.querySelector('[data-spinner]')).toBeNull()
expect(testContainer.querySelector('.bg-agent-monitoring')).not.toBeNull()
expect(testContainer.querySelector('.lucide-radio')?.classList).toContain('text-yellow-500')
expect(dotLabels()).toEqual(['Monitoring background tasks'])
})
@@ -276,7 +276,7 @@ describe('palette live status', () => {
})
expect(testContainer.querySelector('[data-spinner]')).toBeNull()
expect(testContainer.querySelector('.bg-agent-monitoring')).not.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()
})
@@ -420,10 +420,8 @@
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(--agent-monitoring);
stroke: var(--color-yellow-500);
}
.fleet-status-blocked .agent-map-agent-mark {
@@ -362,7 +362,8 @@ describe('DashboardAgentRow', () => {
)
expect(markup).toContain('Monitoring background tasks')
expect(classTokens(markup)).toContain('bg-agent-monitoring')
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')
})
@@ -31,13 +31,13 @@ describe('StatusIndicator', () => {
expect(markup).toContain('motion-reduce:border-t-yellow-500')
})
it('renders monitoring as a static turquoise dot, never a spinner', () => {
it('renders monitoring as a static radio glyph', () => {
const markup = renderMarkup('monitoring')
expect(markup).toContain('title="Monitoring background tasks"')
expect(markup).toContain('bg-agent-monitoring')
expect(markup).toContain('lucide-radio')
expect(markup).toContain('text-yellow-500')
expect(markup).not.toContain('data-agent-spinner')
expect(markup).not.toContain('bg-emerald-500')
})
it('renders permission as the shared question glyph', () => {
@@ -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
@@ -66,11 +79,7 @@ const StatusIndicator = React.memo(function StatusIndicator({
// 'active' (terminal open, quiet). Working uses a yellow
// ring above; 'inactive' stays grey.
'bg-emerald-500'
: // 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'
: 'bg-neutral-500/40'
)}
/>
</span>