diff --git a/mobile/src/components/AgentSpinner.tsx b/mobile/src/components/AgentSpinner.tsx index 4c476693de1..a5e81ec07a9 100644 --- a/mobile/src/components/AgentSpinner.tsx +++ b/mobile/src/components/AgentSpinner.tsx @@ -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 = { 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 ( - + ) } diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx index b1c6514b922..4d596b7e094 100644 --- a/mobile/src/components/AgentStateDot.tsx +++ b/mobile/src/components/AgentStateDot.tsx @@ -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, string> = { +const DOT_COLORS: Record, 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 ( + + + + ) + } + return ( - + ) diff --git a/mobile/src/components/agent-monitoring-indicators.test.ts b/mobile/src/components/agent-monitoring-indicators.test.ts index 4bfab357270..749d2636b98 100644 --- a/mobile/src/components/agent-monitoring-indicators.test.ts +++ b/mobile/src/components/agent-monitoring-indicators.test.ts @@ -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 }[] + findByType(type: string): { props: Record } } 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() }) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 54fa168dda6..9d75836cd11 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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); diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index cf689be4d3c..6304b1cfd68 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index 19973949a8d..4f361d01f87 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -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 ( + + + ) + } + 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' )} /> diff --git a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx index 13f6f03e60f..d0a9ea6b619 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx @@ -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() }) diff --git a/src/renderer/src/components/dashboard-popout/agent-map.css b/src/renderer/src/components/dashboard-popout/agent-map.css index 4d20e76946e..189540305a7 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map.css +++ b/src/renderer/src/components/dashboard-popout/agent-map.css @@ -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 { diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index c312517bdd1..9a95a6293e8 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -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') }) diff --git a/src/renderer/src/components/sidebar/StatusIndicator.test.ts b/src/renderer/src/components/sidebar/StatusIndicator.test.ts index f28c43cb140..89fdcb12f75 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.test.ts +++ b/src/renderer/src/components/sidebar/StatusIndicator.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx index 1d63739c813..97726487c19 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.tsx +++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx @@ -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 ( + + + ) + } + if (status === 'permission') { return (