From 653b268119378b60b62c5daaef38e12d09a8d3a1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:35:38 -0700 Subject: [PATCH] fix(ui): label agent state glyphs and swap monitoring to a heartbeat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The monitoring glyph read as unlabeled: AgentStateDot set only aria-label, which renders no hover tooltip, so hovering it showed the row's own title — the same truncated text already visible. Its row siblings (agent icon, model chip) both had hover titles, leaving this glyph the odd one out. Give every state a native title in the shared primitive, so done/working/ blocked/idle gain the same affordance across the sidebar, tab bar, dashboard, kanban, cmd-J palette and AI Vault at once. Callers can override via a new optional title prop; AiVaultSessionSubagents drops its now-redundant wrapper. Native title rather than the Radix tooltip: AgentStateDot renders in two surfaces with no TooltipProvider above it — the dashboard popout is its own React root, and AgentMapScene — so Radix would throw there. StatusIndicator already sets a native title for the same reason. Also swap lucide Radio for Activity. Radio reads as "broadcasting"; the heartbeat line reads as "still running", which is what the state means. Mobile keeps its documented 1:1 parity with the desktop primitive. Fixes STA-5794 --- mobile/src/components/AgentSpinner.tsx | 4 +- mobile/src/components/AgentStateDot.tsx | 4 +- .../agent-monitoring-indicators.test.ts | 10 ++--- .../src/components/AgentStateDot.test.ts | 41 +++++++++++++++++-- src/renderer/src/components/AgentStateDot.tsx | 17 ++++++-- .../cmd-j/palette-live-status.test.tsx | 4 +- .../dashboard/DashboardAgentRow.test.tsx | 2 +- .../right-sidebar/AiVaultSessionSubagents.tsx | 4 +- .../sidebar/StatusIndicator.test.ts | 4 +- .../components/sidebar/StatusIndicator.tsx | 4 +- .../sidebar/WorktreeCardAgents.test.tsx | 4 +- 11 files changed, 73 insertions(+), 25 deletions(-) diff --git a/mobile/src/components/AgentSpinner.tsx b/mobile/src/components/AgentSpinner.tsx index a5e81ec07a9..c05e5d2a489 100644 --- a/mobile/src/components/AgentSpinner.tsx +++ b/mobile/src/components/AgentSpinner.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react' -import { Radio } from 'lucide-react-native' +import { Activity } from 'lucide-react-native' import { Animated, Easing, StyleSheet, View } from 'react-native' import type { AgentWorkingMode } from '../../../src/shared/agent-status-types' @@ -50,7 +50,7 @@ export function AgentSpinner({ if (monitoring) { return ( - + ) } diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx index 4d596b7e094..1637296d900 100644 --- a/mobile/src/components/AgentStateDot.tsx +++ b/mobile/src/components/AgentStateDot.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react' -import { Radio } from 'lucide-react-native' +import { Activity } from 'lucide-react-native' import { Animated, Easing, StyleSheet, View } from 'react-native' import type { AgentDotState } from '../worktree/agent-row-display' @@ -49,7 +49,7 @@ export function AgentStateDot({ state }: { state: AgentDotState }) { if (state === 'monitoring') { return ( - + ) } diff --git a/mobile/src/components/agent-monitoring-indicators.test.ts b/mobile/src/components/agent-monitoring-indicators.test.ts index 749d2636b98..eefa2137f14 100644 --- a/mobile/src/components/agent-monitoring-indicators.test.ts +++ b/mobile/src/components/agent-monitoring-indicators.test.ts @@ -19,7 +19,7 @@ const { animationLoop, animationTiming, setValue } = vi.hoisted(() => ({ setValue: vi.fn() })) -vi.mock('lucide-react-native', () => ({ Radio: 'Radio' })) +vi.mock('lucide-react-native', () => ({ Activity: 'Activity' })) vi.mock('react-native', () => ({ Animated: { Value: function Value() { @@ -48,12 +48,12 @@ describe('mobile monitoring indicators', () => { renderer = null }) - it('renders a static Radio for a monitoring agent', async () => { + it('renders a static Activity heartbeat for a monitoring agent', async () => { await act(async () => { renderer = create(createElement(AgentStateDot, { state: 'monitoring' })) }) - expect(renderer?.root.findByType('Radio').props).toMatchObject({ + expect(renderer?.root.findByType('Activity').props).toMatchObject({ color: DESKTOP_WORKING_COLOR, size: 10 }) @@ -61,14 +61,14 @@ describe('mobile monitoring indicators', () => { expect(animationLoop).not.toHaveBeenCalled() }) - it('renders a static Radio for an all-monitoring workspace', async () => { + it('renders a static Activity heartbeat for an all-monitoring workspace', async () => { await act(async () => { renderer = create( createElement(AgentSpinner, { status: 'working', workingMode: 'monitoring' }) ) }) - expect(renderer?.root.findByType('Radio').props).toMatchObject({ + expect(renderer?.root.findByType('Activity').props).toMatchObject({ color: DESKTOP_WORKING_COLOR, size: 12 }) diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index 6304b1cfd68..c3819152665 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' -import { AgentStateDot, type AgentDotState } from './AgentStateDot' +import { AgentStateDot, agentStateLabel, type AgentDotState } from './AgentStateDot' function renderMarkup(state: AgentDotState): string { return renderToStaticMarkup(React.createElement(AgentStateDot, { state })) @@ -42,11 +42,11 @@ 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 yellow heartbeat glyph', () => { const markup = renderMarkup('monitoring') expect(markup).toContain('aria-label="Monitoring background tasks"') - expect(markup).toContain('lucide-radio') + expect(markup).toContain('lucide-activity') expect(markup).toContain('text-yellow-500') expect(markup).not.toContain('data-agent-spinner') }) @@ -86,4 +86,39 @@ describe('AgentStateDot', () => { expect(classNames).not.toContain('bg-amber-500') } ) + + // Why: aria-label renders no hover tooltip, so every glyph read as unlabeled + // to sighted users until each branch also emitted a native title (STA-5794). + const ALL_STATES = [ + 'working', + 'monitoring', + 'blocked', + 'waiting', + 'interrupted', + 'failed', + 'done', + 'idle', + 'permission' + ] satisfies AgentDotState[] + + it.each(ALL_STATES)('labels %s with a native hover tooltip', (state) => { + const title = renderMarkup(state).match(/]*\stitle="([^"]*)"/)?.[1] + + expect(title).toBe(agentStateLabel(state)) + }) + + // Typecheck-time guard: a new AgentDotState member that ALL_STATES omits + // fails `pnpm tc`, so the tooltip case above can never silently skip a state. + type UncoveredState = Exclude + const _allStatesAreCovered: UncoveredState extends never ? true : never = true + void _allStatesAreCovered + + it('lets a caller override the tooltip', () => { + const markup = renderToStaticMarkup( + React.createElement(AgentStateDot, { state: 'done', title: 'Finished 2m ago' }) + ) + + expect(markup).toContain('title="Finished 2m ago"') + expect(markup).toContain('aria-label="Done"') + }) }) diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index 4f361d01f87..5aa9b5027e4 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, Radio } from 'lucide-react' +import { Activity, CircleCheck } from 'lucide-react' import { cn } from '@/lib/utils' import { AgentQuestionIcon } from '@/components/AgentQuestionIcon' import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' @@ -61,23 +61,30 @@ type Props = { state: AgentDotState size?: 'sm' | 'md' className?: string + /** Overrides the native hover tooltip; defaults to the state's label. */ + title?: string } /** Render the compact state glyph used by agent rows and terminal tabs. */ export const AgentStateDot = React.memo(function AgentStateDot({ state, size = 'sm', - className + className, + title }: Props): React.JSX.Element { const box = size === 'md' ? 'h-3 w-3' : 'h-2.5 w-2.5' const inner = size === 'md' ? 'size-2' : 'size-1.5' const icon = size === 'md' ? 'size-3' : 'size-2.5' + // Why: aria-label alone never renders a hover tooltip, so these glyphs read as + // unlabeled to sighted users (STA-5794). Mirrors StatusIndicator's `title`. + const resolvedTitle = title ?? agentStateLabel(state) if (state === 'working') { return ( @@ -89,8 +96,9 @@ export const AgentStateDot = React.memo(function AgentStateDot({ - ) } @@ -104,6 +112,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({ @@ -115,6 +124,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({ @@ -125,6 +135,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({ { await render() expect(testContainer.querySelector('[data-spinner]')).toBeNull() - expect(testContainer.querySelector('.lucide-radio')?.classList).toContain('text-yellow-500') + expect(testContainer.querySelector('.lucide-activity')?.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('.lucide-radio')?.classList).toContain('text-yellow-500') + expect(testContainer.querySelector('.lucide-activity')?.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/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index 9a95a6293e8..85a8a4a45e6 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -362,7 +362,7 @@ describe('DashboardAgentRow', () => { ) expect(markup).toContain('Monitoring background tasks') - expect(markup).toContain('lucide-radio') + expect(markup).toContain('lucide-activity') 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/right-sidebar/AiVaultSessionSubagents.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.tsx index 6fbde077a9c..fbea6e9edfe 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.tsx @@ -3,7 +3,7 @@ import type React from 'react' import { Bot, FileJson } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot' +import { AgentStateDot, type AgentDotState } from '@/components/AgentStateDot' import type { AiVaultSession, AiVaultSubagentRunStatus } from '../../../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { canOpenAiVaultSessionLogInOrca } from './ai-vault-session-path-actions' @@ -120,7 +120,7 @@ function SubagentSessionLine({ session }: { session: AiVaultSession }): React.JS {dotState ? ( // Why: a plain inline span would baseline-align the dot; flex keeps it // vertically centered with the row text. - + ) : null} diff --git a/src/renderer/src/components/sidebar/StatusIndicator.test.ts b/src/renderer/src/components/sidebar/StatusIndicator.test.ts index c7ebd3a59ec..e5091cead7f 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.test.ts +++ b/src/renderer/src/components/sidebar/StatusIndicator.test.ts @@ -31,11 +31,11 @@ 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 heartbeat glyph', () => { const markup = renderMarkup('monitoring') expect(markup).toContain('title="Monitoring background tasks"') - expect(markup).toContain('lucide-radio') + expect(markup).toContain('lucide-activity') expect(markup).toContain('text-yellow-500') expect(markup).not.toContain('data-agent-spinner') }) diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx index 26493bb7d40..af91ab65487 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.tsx +++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Radio } from 'lucide-react' +import { Activity } from 'lucide-react' import { cn } from '@/lib/utils' import { AgentQuestionIcon } from '@/components/AgentQuestionIcon' import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' @@ -48,7 +48,7 @@ const StatusIndicator = React.memo(function StatusIndicator({ title={resolvedTitle} {...rest} > - ) } diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx index d5591b9e9d5..15927398b85 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx @@ -771,7 +771,9 @@ describe('WorktreeCardAgents', () => { const markup = renderToStaticMarkup() const iconTitles = [...markup.matchAll(/title="([^"]+)"/g)].map((match) => match[1]) - expect(iconTitles).toEqual([]) + // Variety icons stay identity-free; the lone title is the group's state + // label, which every state glyph now carries (STA-5794). + expect(iconTitles).toEqual(['Working']) expect(markup).not.toContain('>5 working<') expect(markup).toContain('>+2<') })