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:
parent
a356b9d5c2
commit
b63d4cde28
|
|
@ -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')
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue