mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user