fix(ui): stop the state dot from shadowing a row's disabled reason

The shared AgentStateDot now emits a native title on every state, so at
any call site nested inside an element that already has a title, the
dot's generic state word wins on hover over the more useful ancestor
text. That regressed the sidebar agent row, which carries
`sendTargetDisabledReason ?? rowTitle`: hovering the dot showed
"Working" instead of the actionable send-target reason. Same guard the
review-notes send menu already uses.

Also covers three hunks that shipped untested: the Radix opt-outs in
ActivityPrototypePage and the AI Vault subagent line's dropped wrapper
title both stayed green when reverted, and the suppression test was a
`not.toContain` sweep that passed against the pre-fix tree.
This commit is contained in:
Brennan Benson
2026-08-28 01:45:03 -07:00
parent 173ff4e0b7
commit dc89a0d862
6 changed files with 91 additions and 4 deletions
@@ -127,7 +127,11 @@ describe('AgentStateDot', () => {
React.createElement(AgentStateDot, { state: 'interrupted', title: null })
)
expect(markup).not.toContain(' title=')
expect(markup.match(/<span[^>]*\stitle="([^"]*)"/)?.[1]).toBe(undefined)
expect(markup).toContain('aria-label="Interrupted"')
// Differential: the same state still labels itself when the caller omits `title`.
expect(renderMarkup('interrupted').match(/<span[^>]*\stitle="([^"]*)"/)?.[1]).toBe(
agentStateLabel('interrupted')
)
})
})
@@ -1,11 +1,15 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { formatAgentTypeLabel } from '@/lib/agent-status'
import { TooltipProvider } from '@/components/ui/tooltip'
import {
buildActivityThreadGroups,
buildActivityEvents,
buildAgentPaneThreads,
getActivityThreadGroup
getActivityThreadGroup,
ThreadAgentStateIndicator
} from './ActivityPrototypePage'
import {
makeActivityResult,
@@ -23,6 +27,28 @@ import {
UNKNOWN_PANE_KEY
} from './ActivityPrototypePage-test-fixtures'
describe('ThreadAgentStateIndicator', () => {
it('labels the state through its Radix tooltip only', () => {
const threads = makeThreads(
makeActivityResult({ entries: { [PANE_KEY]: makeWorkingEntryWithoutHistory() } })
)
const markup = renderToStaticMarkup(
createElement(
TooltipProvider,
null,
createElement(ThreadAgentStateIndicator, { thread: threads[0]! })
)
)
const titles = [...markup.matchAll(/\stitle="([^"]*)"/g)].map((match) => match[1])
expect(markup).toContain('data-slot="tooltip-trigger"')
expect(markup).toContain('aria-label="Working"')
// A native title here would fire alongside the Radix tooltip as a double label.
expect(titles).toEqual([])
})
})
describe('activity thread grouping', () => {
it('status grouping separates interrupted done from normal done and keeps Interrupted label', () => {
const repo = makeRepo()
@@ -1180,7 +1180,11 @@ export function handleActivityFilterFocusShortcut({
return true
}
function ThreadAgentStateIndicator({ thread }: { thread: AgentPaneThread }): React.JSX.Element {
export function ThreadAgentStateIndicator({
thread
}: {
thread: AgentPaneThread
}): React.JSX.Element {
const state = threadAgentState(thread)
const label = threadAgentStateLabel(thread)
return (
@@ -86,6 +86,22 @@ describe('SessionSubagentsSection', () => {
expect(queryByText('First pass')).toBeNull()
})
it('labels the subagent run state exactly once, on the dot itself', async () => {
listSubagentSessions.mockResolvedValueOnce({
sessions: [makeSubagent('Running task')],
issues: []
})
const { container } = render(<SessionSubagentsSection session={makeSession()} />)
await act(async () => {})
const titles = [...container.querySelectorAll('[title]')].map((element) =>
element.getAttribute('title')
)
// A wrapper title would stack a second identical tooltip on the dot's own hit area.
expect(titles).toEqual(['Working', 'Running task', 'View Log'])
})
it('does not fetch for remote sessions even when the scan counted transcripts', async () => {
const { container } = render(
<SessionSubagentsSection
@@ -33,6 +33,10 @@ function monitoringAgent(): DashboardAgentRowData {
}
}
function orderedTitles(markup: string): string[] {
return [...markup.matchAll(/\stitle="([^"]*)"/g)].map((match) => match[1])
}
describe('worktree card agent summary', () => {
it('presents passive working as monitoring', () => {
const agent = monitoringAgent()
@@ -56,6 +60,34 @@ describe('worktree card agent summary', () => {
)
})
it('hands the whole row to the send-target reason, and only then', () => {
const agent = monitoringAgent()
agent.entry.prompt = 'Run background checks'
const disabled = renderToStaticMarkup(
createElement(CompactAgentRow, {
agent,
now: 2000,
onActivate: vi.fn(),
sendTargetStatus: 'disabled',
sendTargetDisabledReason: 'Agent needs permission'
})
)
// The dot sits inside the row, so its own state title would shadow the reason on hover.
expect(orderedTitles(disabled)).toEqual(['Agent needs permission', 'Claude'])
const eligible = renderToStaticMarkup(
createElement(CompactAgentRow, { agent, now: 2000, onActivate: vi.fn() })
)
expect(orderedTitles(eligible)).toEqual([
'Monitoring background tasks - Run background checks',
'Monitoring background tasks',
'Claude'
])
})
it('lists interrupted outcomes before clean completions', () => {
const done = monitoringAgent()
done.state = 'done'
@@ -200,7 +200,12 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
) : reserveDisclosureGutter ? (
<span className="size-4 shrink-0" aria-hidden />
) : null}
<AgentStateDot state={dotState} size="sm" />
{/* Why: the row's actionable disabled reason must win on every hit area. */}
<AgentStateDot
state={dotState}
size="sm"
title={sendTargetDisabledReason ? null : undefined}
/>
{!hideIcon && (
<span className="inline-flex shrink-0" title={formatAgentTypeLabel(agent.agentType)}>
<AgentIcon agent={agentTypeToIconAgent(agent.agentType)} size={13} />