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 && (