From 1da5da2fd83b9527f96a49f34c13660e31c0da4d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 22 May 2026 01:08:08 -0700 Subject: [PATCH] Add interrupted agent row tooltip (#2618) --- .../dashboard/DashboardAgentRow.test.tsx | 71 ++++++----- .../dashboard/DashboardAgentRow.tsx | 113 +++++++++++------- 2 files changed, 113 insertions(+), 71 deletions(-) diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index f5a3aa29c..35df7ec27 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -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( - + + + ) } @@ -114,24 +117,26 @@ describe('DashboardAgentRow', () => { it('keeps each row hover boundary inside an anonymous ancestor group', () => { const markup = renderToStaticMarkup( -
- - -
+ +
+ + +
+
) 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<') }) }) diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 8eefd7561..393833234 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -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. */} - - - + + + + + + + + {dotTooltipLabel} + + {/* 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 ? ( - /'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 ? ( +
+ {isInterrupted && ( + + interrupted + )} - title={!expanded ? lastAssistantMessage : undefined} - /> + {lastAssistantMessage && ( + /'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} + /> + )} +
) : ( !expanded && (