feat(agent-status): question glyph for "needs you" state everywhere (#9996)

* feat(agent-status): show question glyph for needs-you state everywhere

Replace the amber attention dot with the dashboard's MessageCircleQuestion
chat glyph for the waiting/permission "needs you" state across all
surfaces: the agent dashboard cards, in-app dashboard rows, the sidebar's
Agent Dashboard quick-indicator counts, the worktree-level status dot, and
the shared agent-row/terminal-tab indicators.

On the dashboard card the header glyph is suppressed when a question
summary pill is present so "needs you" reads once, not twice.

* test(agent-status): assert amber question glyph, not amber dot

The needs-you unification replaced the amber dot with the amber
MessageCircleQuestion glyph, so update the remaining state assertions in
DashboardAgentRow, WorktreeCardStatusSlot, and TerminalTabLeadingIcon to
match (lucide-message-circle-question + text-amber-500).

* test(agent-status): cover needs-you glyph surfaces
This commit is contained in:
Brennan Benson 2026-07-22 16:15:25 -07:00 committed by GitHub
parent a356b9d5c2
commit b63d4cde28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 123 additions and 35 deletions

View File

@ -47,12 +47,14 @@ describe('AgentStateDot', () => {
})
it.each(['permission', 'waiting'] satisfies AgentDotState[])(
'renders %s as an amber attention dot',
'renders %s as an amber question glyph',
(state) => {
const classNames = renderDotClassNames(state)
const markup = renderMarkup(state)
expect(classNames).toContain('bg-amber-500')
expect(classNames).not.toContain('bg-red-500')
expect(markup).toContain('lucide-message-circle-question-mark')
expect(markup).toContain('text-amber-500')
expect(markup).not.toContain('bg-amber-500')
expect(markup).not.toContain('data-agent-spinner')
}
)

View File

@ -1,5 +1,5 @@
import React from 'react'
import { CircleCheck } from 'lucide-react'
import { CircleCheck, MessageCircleQuestion } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
@ -95,6 +95,17 @@ export const AgentStateDot = React.memo(function AgentStateDot({
)
}
if (state === 'permission' || state === 'waiting') {
return (
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
aria-label={agentStateLabel(state)}
>
<MessageCircleQuestion className={cn('text-amber-500', icon)} aria-hidden="true" />
</span>
)
}
return (
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
@ -104,11 +115,9 @@ export const AgentStateDot = React.memo(function AgentStateDot({
className={cn(
'block rounded-full',
inner,
state === 'permission' || state === 'waiting'
? 'bg-amber-500'
: state === 'blocked' || state === 'interrupted' || state === 'failed'
? 'bg-red-500'
: 'bg-neutral-500/40'
state === 'blocked' || state === 'interrupted' || state === 'failed'
? 'bg-red-500'
: 'bg-neutral-500/40'
)}
/>
</span>

View File

@ -60,6 +60,30 @@ describe('AgentKanbanCard', () => {
expect(screen.queryByText(/\d+d/)).not.toBeInTheDocument()
})
it('shows the question glyph once when a summary is available', () => {
const attentionCard = card({
bucket: 'attention',
dotState: 'waiting',
askSummary: 'Approve deploy?'
})
const { container, rerender } = render(
<AgentKanbanCard card={attentionCard} now={2_000} onOpenTerminal={vi.fn()} />
)
expect(screen.queryByTestId('state-dot')).not.toBeInTheDocument()
expect(container.querySelectorAll('.lucide-message-circle-question-mark')).toHaveLength(1)
rerender(
<AgentKanbanCard
card={{ ...attentionCard, askSummary: undefined }}
now={2_000}
onOpenTerminal={vi.fn()}
/>
)
expect(screen.getByTestId('state-dot')).toBeInTheDocument()
expect(container.querySelector('.lucide-message-circle-question-mark')).toBeNull()
})
it('skips structured-clone rerenders until visible card data or its age changes', () => {
const onOpenTerminal = vi.fn()
const initial = card({ startedAt: 1_000 })

View File

@ -98,7 +98,8 @@ export const AgentKanbanCard = memo(
>
{card.worktreeName}
</span>
<AgentStateDot state={card.dotState} className="ml-auto" />
{/* The summary pill already carries the attention glyph. */}
{card.askSummary ? null : <AgentStateDot state={card.dotState} className="ml-auto" />}
</div>
{card.lastUserMessage || card.lastAgentMessage ? (

View File

@ -247,12 +247,13 @@ describe('DashboardAgentRow', () => {
expect(classes.every((className) => !/\bgroup-hover:/.test(className))).toBe(true)
})
it('renders waiting rows with the amber permission color', () => {
it('renders waiting rows with the amber question glyph', () => {
const markup = renderRow(makeAgent({}, { state: 'waiting' }))
const tokens = classTokens(markup)
expect(markup).toContain('aria-label="Waiting for input"')
expect(tokens).toContain('bg-amber-500')
expect(markup).toContain('lucide-message-circle-question-mark')
expect(tokens).toContain('text-amber-500')
expect(tokens).not.toContain('bg-red-500')
})

View File

@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({
refreshPreflightStatus: vi.fn(),
checkLinearConnection: vi.fn(),
hasPairedMobileDevice: false,
agentBucketCounts: { attention: 0, working: 0, idle: 0 },
dismissMobileOnboardingBadge: vi.fn(),
setSetupGuideSidebarDismissed: vi.fn()
}))
@ -39,6 +40,10 @@ vi.mock('@/components/activity/useActivityUnreadCount', () => ({
useActivityUnreadCount: () => 0
}))
vi.mock('@/components/dashboard/useAgentBucketCounts', () => ({
useAgentBucketCounts: () => mocks.agentBucketCounts
}))
vi.mock('@/hooks/useShortcutLabel', () => ({
useShortcutKeyComboDetails: () => [{ keys: ['⌘', 'J'], doubleTap: false }]
}))
@ -203,6 +208,7 @@ describe('SidebarNav', () => {
vi.clearAllMocks()
await i18n.changeLanguage('en')
mocks.hasPairedMobileDevice = false
mocks.agentBucketCounts = { attention: 0, working: 0, idle: 0 }
setSidebarState()
})
@ -252,6 +258,26 @@ describe('SidebarNav', () => {
expect(queryButtonByText(container, 'Agent Dashboard')).not.toBeNull()
})
it('uses a question glyph only for the Needs You count', async () => {
mocks.agentBucketCounts = { attention: 2, working: 3, idle: 4 }
setSidebarState({
settings: {
...getDefaultSettings('/tmp'),
experimentalAgentDashboardPopout: true
}
})
const container = await renderSidebarNav()
const attention = container.querySelector('[aria-label="Needs You: 2"]')
const working = container.querySelector('[aria-label="Working: 3"]')
const idle = container.querySelector('[aria-label="Idle: 4"]')
expect(attention?.querySelector('.lucide-message-circle-question-mark')).not.toBeNull()
expect(working?.querySelector('.rounded-full')).not.toBeNull()
expect(idle?.querySelector('.rounded-full')).not.toBeNull()
expect(working?.querySelector('svg')).toBeNull()
expect(idle?.querySelector('svg')).toBeNull()
})
it('shows the Mobile entry by default for older settings', () => {
expect(shouldShowMobileButton(null)).toBe(true)
expect(shouldShowMobileButton({})).toBe(true)

View File

@ -1,5 +1,13 @@
import React from 'react'
import { Bell, CalendarClock, EyeOff, LayoutDashboard, Search, Smartphone } from 'lucide-react'
import {
Bell,
CalendarClock,
EyeOff,
LayoutDashboard,
MessageCircleQuestion,
Search,
Smartphone
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
@ -44,10 +52,7 @@ export function shouldShowAutomationsButton(
return settings?.showAutomationsButton !== false
}
// Per-state dot colors mirror AgentStateDot so the sidebar counts read the same
// as the board (amber = needs you, yellow = working, neutral = idle, emerald = done).
const DASHBOARD_BUCKET_DOT_CLASS: Record<DashboardBucket, string> = {
attention: 'bg-amber-500',
const DASHBOARD_BUCKET_DOT_CLASS: Record<'working' | 'idle', string> = {
working: 'bg-yellow-500',
idle: 'bg-neutral-500/50'
}
@ -80,7 +85,11 @@ function DashboardBucketCounts({
aria-label={`${dashboardBucketLabel(bucket)}: ${counts[bucket]}`}
className="inline-flex items-center gap-1 text-[10px] tabular-nums text-worktree-sidebar-foreground/55"
>
<span className={cn('size-1.5 rounded-full', DASHBOARD_BUCKET_DOT_CLASS[bucket])} />
{bucket === 'attention' ? (
<MessageCircleQuestion className="size-2.5 text-amber-500" aria-hidden />
) : (
<span className={cn('size-1.5 rounded-full', DASHBOARD_BUCKET_DOT_CLASS[bucket])} />
)}
{counts[bucket]}
</span>
))}

View File

@ -32,11 +32,13 @@ describe('StatusIndicator', () => {
expect(markup).not.toContain('animation:spin')
})
it('renders permission as an amber attention dot', () => {
const classNames = renderDotClassNames('permission')
it('renders permission as an amber question glyph', () => {
const markup = renderMarkup('permission')
expect(classNames).toContain('bg-amber-500')
expect(classNames).not.toContain('bg-red-500')
expect(markup).toContain('lucide-message-circle-question-mark')
expect(markup).toContain('text-amber-500')
expect(markup).not.toContain('bg-amber-500')
expect(markup).not.toContain('data-agent-spinner')
})
it('renders active as full emerald dot', () => {

View File

@ -1,4 +1,5 @@
import React from 'react'
import { MessageCircleQuestion } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-status'
@ -39,6 +40,18 @@ const StatusIndicator = React.memo(function StatusIndicator({
)
}
if (status === 'permission') {
return (
<span
className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}
title={resolvedTitle}
{...rest}
>
<MessageCircleQuestion className="size-3 text-amber-500" aria-hidden="true" />
</span>
)
}
return (
<span
className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}
@ -48,14 +61,12 @@ const StatusIndicator = React.memo(function StatusIndicator({
<span
className={cn(
'block size-2 rounded-full',
status === 'permission'
? 'bg-amber-500'
: status === 'done' || status === 'active'
? // Green dot for both hook-reported 'done' and the heuristic
// 'active' (terminal open, quiet). Working uses a yellow
// ring above; 'inactive' stays grey.
'bg-emerald-500'
: 'bg-neutral-500/40'
status === 'done' || status === 'active'
? // Green dot for both hook-reported 'done' and the heuristic
// 'active' (terminal open, quiet). Working uses a yellow
// ring above; 'inactive' stays grey.
'bg-emerald-500'
: 'bg-neutral-500/40'
)}
/>
</span>

View File

@ -126,7 +126,8 @@ describe('WorktreeCardStatusSlot', () => {
)
expect(markup).toContain('Needs permission · Unread')
expect(markup).toContain('bg-amber-500')
expect(markup).toContain('lucide-message-circle-question-mark')
expect(markup).toContain('text-amber-500')
expect(markup).not.toContain('data-worktree-status-lane-unread=""')
expect(markup).not.toContain('data-worktree-unread-alert=""')
expect(markup).not.toContain('aria-label="Mark as read"')
@ -378,7 +379,8 @@ describe('WorktreeCardStatusSlot', () => {
)
expect(markup).toContain('Needs permission')
expect(markup).toContain('bg-amber-500')
expect(markup).toContain('lucide-message-circle-question-mark')
expect(markup).toContain('text-amber-500')
expect(markup).not.toContain('PR checks: Failed')
})

View File

@ -36,11 +36,12 @@ describe('TerminalTabLeadingIcon', () => {
expect(markup).toContain('data-agent-icon="codex"')
})
it('shows a needs-input (permission) state as an amber dot', () => {
it('shows a needs-input (permission) state as an amber question glyph', () => {
const markup = renderStatus('permission')
expect(markup).toContain('data-agent-activity-status="permission"')
expect(markup).toContain('bg-amber-500')
expect(markup).toContain('lucide-message-circle-question-mark')
expect(markup).toContain('text-amber-500')
expect(markup).not.toContain('bg-red-500')
})