Add interrupted agent row tooltip (#2618)
This commit is contained in:
parent
b926490495
commit
1da5da2fd8
|
|
@ -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<')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue