Add interrupted agent row tooltip (#2618)

This commit is contained in:
Neil 2026-05-22 01:08:08 -07:00 committed by GitHub
parent b926490495
commit 1da5da2fd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 113 additions and 71 deletions

View File

@ -2,6 +2,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { TerminalTab } from '../../../../shared/types'
import { TooltipProvider } from '../ui/tooltip'
import DashboardAgentRow from './DashboardAgentRow'
import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData'
@ -46,14 +47,16 @@ function makeAgent(
function renderRow(agent: DashboardAgentRowData): string {
return renderToStaticMarkup(
<DashboardAgentRow
agent={agent}
onDismiss={vi.fn()}
onActivate={vi.fn()}
now={NOW}
hideIdentityIcon
hideExpand
/>
<TooltipProvider>
<DashboardAgentRow
agent={agent}
onDismiss={vi.fn()}
onActivate={vi.fn()}
now={NOW}
hideIdentityIcon
hideExpand
/>
</TooltipProvider>
)
}
@ -114,24 +117,26 @@ describe('DashboardAgentRow', () => {
it('keeps each row hover boundary inside an anonymous ancestor group', () => {
const markup = renderToStaticMarkup(
<div className="group">
<DashboardAgentRow
agent={makeAgent({ paneKey: 'tab-1:leaf-1' })}
onDismiss={vi.fn()}
onActivate={vi.fn()}
now={NOW}
hideIdentityIcon
hideExpand
/>
<DashboardAgentRow
agent={makeAgent({ paneKey: 'tab-1:leaf-2' })}
onDismiss={vi.fn()}
onActivate={vi.fn()}
now={NOW}
hideIdentityIcon
hideExpand
/>
</div>
<TooltipProvider>
<div className="group">
<DashboardAgentRow
agent={makeAgent({ paneKey: 'tab-1:leaf-1' })}
onDismiss={vi.fn()}
onActivate={vi.fn()}
now={NOW}
hideIdentityIcon
hideExpand
/>
<DashboardAgentRow
agent={makeAgent({ paneKey: 'tab-1:leaf-2' })}
onDismiss={vi.fn()}
onActivate={vi.fn()}
now={NOW}
hideIdentityIcon
hideExpand
/>
</div>
</TooltipProvider>
)
const classes = hoverSwapClasses(markup)
@ -142,7 +147,7 @@ describe('DashboardAgentRow', () => {
expect(classes.every((className) => !/\bgroup-hover:/.test(className))).toBe(true)
})
it('renders interrupted done rows as a leading state icon without a text badge', () => {
it('renders interrupted done rows with plain text on the secondary line', () => {
const markup = renderRow(
makeAgent(
{ state: 'done', startedAt: 1_000 },
@ -156,12 +161,16 @@ describe('DashboardAgentRow', () => {
}
)
)
const promptIndex = markup.indexOf('Give me a quick update')
const interruptedIndex = markup.indexOf('>interrupted<')
// Why: interrupted now lives in the leading state column. The old
// right-side text badge should not come back.
expect(markup).toContain('title="Interrupted"')
// Why: interrupted keeps the leading red dot, but the plain text belongs
// on the response line so it does not compete with the user's prompt.
expect(markup).toContain('data-slot="tooltip-trigger"')
expect(markup).toContain('aria-label="Interrupted by user"')
expect(markup).toContain('bg-red-500')
expect(markup).not.toContain('data-slot="badge"')
expect(interruptedIndex).toBeGreaterThan(promptIndex)
expect(markup).not.toContain('lucide-circle-check')
expect(markup).not.toContain('>interrupted<')
})
})

View File

@ -5,6 +5,7 @@ import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData'
@ -59,6 +60,13 @@ function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null {
return null
}
function stateDotTooltipLabel(agent: DashboardAgentRowData, dotState: AgentDotState): string {
if (agent.entry.interrupted === true) {
return 'Interrupted by user'
}
return agentStateLabel(dotState)
}
type Props = {
agent: DashboardAgentRowData
onDismiss: (paneKey: string) => void
@ -176,10 +184,12 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
const toolName = isWorking ? (agent.entry.toolName?.trim() ?? '') : ''
const toolInput = isWorking ? (agent.entry.toolInput?.trim() ?? '') : ''
const lastAssistantMessage = agent.entry.lastAssistantMessage?.trim() ?? ''
const isInterrupted = agent.entry.interrupted === true
// Why: interrupted is a terminal outcome the user needs to scan in the
// leading state column; rendering it as right-side text competes with the
// timestamp and makes the row read like the old design.
const dotState: AgentDotState = agent.entry.interrupted ? 'interrupted' : asDotState(agent.state)
// leading state column; the secondary-line text below provides the
// explanation without competing with the prompt or timestamp.
const dotState: AgentDotState = isInterrupted ? 'interrupted' : asDotState(agent.state)
const dotTooltipLabel = stateDotTooltipLabel(agent, dotState)
// Why: always show the chevron to keep the row's right edge stable — a
// conditional control would appear/disappear as agent content grows and
@ -227,12 +237,19 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
bar + right-side dot combo, which double-encoded state. Size md
gives the glyph enough presence for the leading slot without
overpowering the prompt text. */}
<span
className="inline-flex shrink-0 items-center justify-center"
title={agentStateLabel(dotState)}
>
<AgentStateDot state={dotState} size={stateDotSize} />
</span>
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex shrink-0 items-center justify-center"
aria-label={dotTooltipLabel}
>
<AgentStateDot state={dotState} size={stateDotSize} />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{dotTooltipLabel}
</TooltipContent>
</Tooltip>
{/* Why: identity (Claude/Codex/Gemini/) sits inline with the prompt
so the reader gets "state → who → what they said" left-to-right
on the top row. The sub-rows (tool step, assistant response) are
@ -424,38 +441,54 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
expand animation lives on the CommentMarkdown itself (height +
interpolate-size) so the body reveals smoothly instead of snapping
open. When the message is empty we still render a placeholder in
the collapsed view to preserve the reserved line height. */}
{lastAssistantMessage ? (
<CommentMarkdown
content={lastAssistantMessage}
// Why: animate between a 1-line clipped height and the content's
// natural height using Chromium's `interpolate-size: allow-keywords`
// so the message body expands/collapses smoothly instead of
// snapping. Height transition + overflow-hidden keeps the inline-
// flattened preview clipped during the interpolation. Render the
// markdown in both states; in the collapsed view we force every
// nested element inline so `truncate` can ellipsize the whole
// thing on one line. The [&_*]:inline descendant selector flattens
// the markdown tree (lists, pre, headings, blockquotes) into inline
// flow; block margins and list markers are suppressed by
// [&_*]:!m-0 / [&_ul]:list-none so the preview reads as a single
// clean line.
className={cn(
'mt-0.5 overflow-hidden pl-5 text-[10px] leading-snug text-muted-foreground/80',
'transition-[height] duration-200 ease-out [interpolate-size:allow-keywords]',
expanded ? 'h-auto' : 'h-[1lh]',
// Why: in collapsed mode we need a single truncated line. Markdown
// blocks (pre, lists, headings) are flattened inline and forced
// to inherit `white-space: nowrap` so <pre>/<code>'s preserved
// newlines don't break out of the truncation container. The
// `!` prefixes override CommentMarkdown's own layout styles so
// nothing (margins, list markers, block line-breaks) can push
// the preview onto a second line.
!expanded &&
'truncate whitespace-nowrap [&_*]:inline [&_*]:!whitespace-nowrap [&_*]:!m-0 [&_*]:!p-0 [&_ul]:list-none [&_ol]:list-none [&_br]:hidden'
the collapsed view to preserve the reserved line height.
Interrupted gets its visible text on this secondary line, where the
agent response normally appears. That keeps the prompt line clean
while making the red status dot's meaning visible without hover. */}
{isInterrupted || lastAssistantMessage ? (
<div className="mt-0.5 flex min-w-0 items-start gap-1.5 pl-5">
{isInterrupted && (
<span
className="shrink-0 text-[10px] leading-snug text-muted-foreground/80"
aria-label="Interrupted by user"
>
interrupted
</span>
)}
title={!expanded ? lastAssistantMessage : undefined}
/>
{lastAssistantMessage && (
<CommentMarkdown
content={lastAssistantMessage}
// Why: animate between a 1-line clipped height and the content's
// natural height using Chromium's `interpolate-size: allow-keywords`
// so the message body expands/collapses smoothly instead of
// snapping. Height transition + overflow-hidden keeps the inline-
// flattened preview clipped during the interpolation. Render the
// markdown in both states; in the collapsed view we force every
// nested element inline so `truncate` can ellipsize the whole
// thing on one line. The [&_*]:inline descendant selector flattens
// the markdown tree (lists, pre, headings, blockquotes) into inline
// flow; block margins and list markers are suppressed by
// [&_*]:!m-0 / [&_ul]:list-none so the preview reads as a single
// clean line.
className={cn(
'min-w-0 flex-1 overflow-hidden text-[10px] leading-snug text-muted-foreground/80',
'transition-[height] duration-200 ease-out [interpolate-size:allow-keywords]',
expanded ? 'h-auto' : 'h-[1lh]',
// Why: in collapsed mode we need a single truncated line. Markdown
// blocks (pre, lists, headings) are flattened inline and forced
// to inherit `white-space: nowrap` so <pre>/<code>'s preserved
// newlines don't break out of the truncation container. The
// `!` prefixes override CommentMarkdown's own layout styles so
// nothing (margins, list markers, block line-breaks) can push
// the preview onto a second line.
!expanded &&
'truncate whitespace-nowrap [&_*]:inline [&_*]:!whitespace-nowrap [&_*]:!m-0 [&_*]:!p-0 [&_ul]:list-none [&_ol]:list-none [&_br]:hidden'
)}
title={!expanded ? lastAssistantMessage : undefined}
/>
)}
</div>
) : (
!expanded && (
<div className="mt-0.5 pl-5 text-[10px] leading-snug text-muted-foreground/70"> </div>