fix(ui): label agent state glyphs and swap monitoring to a heartbeat

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
This commit is contained in:
Brennan Benson
2026-08-28 01:45:02 -07:00
parent 8fa1b3c16c
commit 653b268119
11 changed files with 73 additions and 25 deletions
+2 -2
View File
@@ -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 (
<View style={styles.wrapper} accessibilityLabel="Monitoring background tasks">
<Radio size={12} color={STATUS_COLORS.working} />
<Activity size={12} color={STATUS_COLORS.working} />
</View>
)
}
+2 -2
View File
@@ -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 (
<View style={styles.wrapper} accessibilityLabel="Monitoring background tasks">
<Radio size={10} color={WORKING_COLOR} />
<Activity size={10} color={WORKING_COLOR} />
</View>
)
}
@@ -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
})
@@ -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(/<span[^>]*\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<AgentDotState, (typeof ALL_STATES)[number]>
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"')
})
})
+14 -3
View File
@@ -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 (
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
aria-label={agentStateLabel(state)}
title={resolvedTitle}
>
<AgentWorkingSpinner className={inner} />
</span>
@@ -89,8 +96,9 @@ export const AgentStateDot = React.memo(function AgentStateDot({
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
aria-label={agentStateLabel(state)}
title={resolvedTitle}
>
<Radio className={cn('text-yellow-500', icon)} aria-hidden="true" />
<Activity className={cn('text-yellow-500', icon)} aria-hidden="true" />
</span>
)
}
@@ -104,6 +112,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
aria-label={agentStateLabel(state)}
title={resolvedTitle}
>
<CircleCheck className={cn('text-emerald-500', icon)} aria-hidden="true" />
</span>
@@ -115,6 +124,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
aria-label={agentStateLabel(state)}
title={resolvedTitle}
>
<AgentQuestionIcon className={icon} />
</span>
@@ -125,6 +135,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
aria-label={agentStateLabel(state)}
title={resolvedTitle}
>
<span
className={cn(
@@ -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('.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()
})
@@ -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')
@@ -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.
<span className="flex shrink-0 items-center" title={agentStateLabel(dotState)}>
<span className="flex shrink-0 items-center">
<AgentStateDot state={dotState} />
</span>
) : null}
@@ -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')
})
@@ -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}
>
<Radio className="size-3 text-yellow-500" aria-hidden="true" />
<Activity className="size-3 text-yellow-500" aria-hidden="true" />
</span>
)
}
@@ -771,7 +771,9 @@ describe('WorktreeCardAgents', () => {
const markup = renderToStaticMarkup(<WorktreeCardAgents worktreeId="wt-1" />)
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<')
})