Skip to content

Commit 0ef2253

Browse files
committed
fix(webapp): one live progress element per turn — the spinner never restarts
The pending tool line, the generic activity row and the investigation card's own progress collapse into a single ChatProgress mounted once at the end of the live turn: phases only swap its label (card phrase > tool phrase > activity), decided in the pure progress-line module. ChatPendingTool is gone; the card renders no spinner of its own; AgentSpinner has exactly one live render site.
1 parent 746f924 commit 0ef2253

9 files changed

Lines changed: 614 additions & 228 deletions

File tree

apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx

Lines changed: 27 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,21 @@ import { Button, LinkButton } from "~/components/primitives/Buttons";
77
import { Callout } from "~/components/primitives/Callout";
88
import { renderPart, toSafeUrl } from "~/components/runs/v3/agent/AgentMessageView";
99
import { sameOriginPath } from "./navigate-target";
10-
import { hasToolProgressLine, IN_FLIGHT_TOOL_STATES } from "./progress-line";
10+
import { IN_FLIGHT_TOOL_STATES, liveProgress, type TurnActivity } from "./progress-line";
1111
import { useTranscriptAutoScroll } from "./useTranscriptAutoScroll";
1212
import {
1313
ChatActionsRow,
1414
ChatCardSlot,
15-
ChatPendingTool,
1615
ChatProgress,
1716
ChatText,
1817
ChatTranscript,
1918
ChatTurn,
2019
} from "./chat-layout";
21-
import { toolPendingLabel } from "./tool-labels";
2220
import { reportBlockFromToolPart } from "./report-block-adapter";
2321
import type { ResolvedUri } from "./ReportView";
2422
import { ViewBlocks } from "./view-catalog";
2523

26-
// "thinking" — the turn is submitted but nothing has come back yet.
27-
// "working" — the turn is streaming: text, or (more often) tool calls, which can
28-
// run for a while with no visible output.
29-
export type TurnActivity = "thinking" | "working";
30-
31-
const ACTIVITY_LABELS: Record<TurnActivity, string> = {
32-
thinking: "Thinking…",
33-
working: "Working…",
34-
};
24+
export type { TurnActivity };
3525

3626
export type DashboardAgentMessagesProps = {
3727
messages: UIMessage[];
@@ -148,19 +138,15 @@ function withoutSupersededInvestigations(
148138
* Everything the panel styles itself is handled here; the rest falls through to
149139
* the shared `renderPart` so agent output still looks the same across the app.
150140
* The differences: text is always the rendered markdown (no raw toggle) at the
151-
* dashboard's default size, and tool calls never show their mechanics — while
152-
* running they are a pending pill ("Reading the queue…"), and once they land
153-
* they leave NO row at all: the answer is the prose and the cards, not the
154-
* input/output plumbing. The one exception is a FAILED call, which keeps its
155-
* error row — a silent failure would read as the agent ignoring the question.
156-
* Citations are handled a level up, where a run of them can be grouped into one
157-
* row.
141+
* dashboard's default size, and tool calls never show their mechanics at all —
142+
* while running they are spoken for by the turn's one progress line ("Reading the
143+
* queue…", mounted at the end of the transcript), and once they land they leave NO
144+
* row: the answer is the prose and the cards, not the input/output plumbing. The
145+
* one exception is a FAILED call, which keeps its error row — a silent failure
146+
* would read as the agent ignoring the question. Citations are handled a level up,
147+
* where a run of them can be grouped into one row.
158148
*/
159-
function renderDashboardPart(
160-
part: UIMessage["parts"][number],
161-
i: number,
162-
options?: { suppressPendingPill?: boolean }
163-
) {
149+
function renderDashboardPart(part: UIMessage["parts"][number], i: number) {
164150
const p = part as {
165151
type: string;
166152
text?: string;
@@ -175,15 +161,12 @@ function renderDashboardPart(
175161
}
176162

177163
if (type.startsWith("tool-")) {
178-
if (IN_FLIGHT_TOOL_STATES.has(p.state ?? "")) {
179-
// One spinner at a time: an in_progress investigation card in this turn
180-
// already shows its own progress pill.
181-
if (options?.suppressPendingPill) return null;
182-
// Stable key across tool changes: only the LABEL changes from call to
183-
// call, so the spinner keeps spinning instead of remounting (and
184-
// restarting its animation) on every new tool.
185-
return <ChatPendingTool key="agent-pending" label={`${toolPendingLabel(type.slice(5))}…`} />;
186-
}
164+
// An in-flight call renders nothing HERE. The turn has exactly one live
165+
// progress element, mounted once at the bottom of the transcript, and this
166+
// call's phrase is simply the label it wears while the call runs (see
167+
// `liveProgress`). Rendering a line per part is what used to remount — and so
168+
// restart — the spinner's animation at every phase change.
169+
if (IN_FLIGHT_TOOL_STATES.has(p.state ?? "")) return null;
187170
if (p.state === "output-error") return renderPart(part, i);
188171
return null;
189172
}
@@ -269,22 +252,6 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
269252
const parts = message.parts ?? [];
270253
if (parts.length === 0) return null;
271254

272-
// An in_progress investigation card carries its own live pill (its
273-
// `progress` line), so a concurrent tool pill would put two spinners on
274-
// screen — the card's, being the more specific, wins.
275-
const hasLiveInvestigationCard = parts.some((part, i) =>
276-
withoutSupersededInvestigations(
277-
blocksFor(part) ?? [],
278-
`${message.id}:${i}`,
279-
investigationWinners
280-
).some(
281-
(block) =>
282-
(block as { type?: string; outcome?: unknown; investigation?: { outcome?: string } })
283-
.type === "investigation" &&
284-
(block as { investigation?: { outcome?: string } }).investigation?.outcome === "in_progress"
285-
)
286-
);
287-
288255
const body: React.ReactNode[] = [];
289256
for (let i = 0; i < parts.length; i++) {
290257
const part = parts[i]!;
@@ -329,7 +296,7 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
329296
continue;
330297
}
331298

332-
body.push(renderDashboardPart(part, i, { suppressPendingPill: hasLiveInvestigationCard }));
299+
body.push(renderDashboardPart(part, i));
333300
}
334301

335302
return <ChatTurn>{body}</ChatTurn>;
@@ -350,13 +317,18 @@ export function DashboardAgentTurns({
350317
resolveUri,
351318
pagePaths,
352319
}: DashboardAgentMessagesProps) {
353-
// One status line at a time: a tool's own progress beats the generic activity.
354-
const showActivity = activity !== null && !hasToolProgressLine(messages);
355-
356320
// Strip once, up front: the winners map keys occurrences by part index, so
357321
// it must be computed on the exact parts the turns will render.
358322
const stripped = messages.map(stripStepParts);
359323

324+
// The turn's ONE live progress element. It is the last child of this fragment,
325+
// which is a fixed slot: adding turns above it, a tool starting or landing, a
326+
// card going live — none of that moves it, so React keeps the same
327+
// `ChatProgress` (and the same animating spinner canvas) mounted for the whole
328+
// turn and only the label changes underneath. Mounting a line per phase, which
329+
// is what this replaced, restarted the animation at every hand-off.
330+
const progress = liveProgress(stripped, activity);
331+
360332
// Across the whole transcript, one card per investigation: the latest
361333
// revision renders where it landed; earlier working copies disappear.
362334
const investigationWinners = winningInvestigationOccurrences(stripped);
@@ -373,9 +345,9 @@ export function DashboardAgentTurns({
373345
investigationWinners={investigationWinners}
374346
/>
375347
))}
376-
{showActivity && activity && (
348+
{progress && (
377349
<ChatTurn>
378-
<ChatProgress>{ACTIVITY_LABELS[activity]}</ChatProgress>
350+
<ChatProgress>{progress.label}</ChatProgress>
379351
</ChatTurn>
380352
)}
381353
{error && (

apps/webapp/app/components/dashboard-agent/InvestigationCard.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ describe("InvestigationCard purity", () => {
4343
expect(source).toMatch(/ChatActionsRow/);
4444
});
4545

46+
it("renders no spinner — the transcript owns the one live progress element", () => {
47+
// The card is re-emitted as the investigation progresses, so a spinner inside
48+
// it restarts its animation on every revision. The transcript's single
49+
// progress line wears the card's `progress` phrase instead (progress-line.ts).
50+
expect(source).not.toMatch(/AgentSpinner|ChatProgress|ChatPendingTool/);
51+
});
52+
4653
it("renders nothing action-shaped without a host to hand intents to", () => {
4754
expect(source).toMatch(/if \(!onIntent \|\| actions\.length === 0\) return null;/);
4855
});

apps/webapp/app/components/dashboard-agent/InvestigationCard.tsx

Lines changed: 87 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
* the data: this reads the validated contracts payload, and the demo card stays
88
* where it is as the reviewed reference.
99
*
10+
* The card shows NO spinner of its own, not even while the investigation is
11+
* unfinished. The transcript has exactly one live progress element for the whole
12+
* turn and the card's `progress` phrase is one of the labels it wears (see
13+
* `progress-line.ts`); a row inside the card would be a second spinner, and one
14+
* that restarts its animation on every revision of the card.
15+
*
1016
* An investigation is the one *progressive* block: its `id` is the
1117
* investigationId and its `revision` climbs, so re-emitting it replaces this card
1218
* rather than stacking a second one (see `view-blocks.ts`).
@@ -31,15 +37,14 @@ import type {
3137
import { useState } from "react";
3238
import { Button } from "~/components/primitives/Buttons";
3339
import { Callout } from "~/components/primitives/Callout";
34-
import { AgentSpinner } from "~/components/primitives/Spinner";
3540
import {
3641
CategoryBadge,
3742
ConfidenceBadge,
3843
EVIDENCE_ROW_CLASS,
3944
SeverityBadge,
4045
VerdictBadge,
4146
} from "./agent-badges";
42-
import { ChatActionsRow, ChatPendingTool } from "./chat-layout";
47+
import { ChatActionsRow } from "./chat-layout";
4348
import type { ResolvedUri } from "./ReportView";
4449

4550
const SEVERITY_LABELS: Record<InvestigationSeverity, string> = {
@@ -114,11 +119,13 @@ function HypothesisRow({
114119
}) {
115120
return (
116121
<li className="space-y-3 border-l-2 border-grid-bright pl-4">
122+
{/* The "Testing" badge is the whole signal — no spinner beside it. The
123+
transcript's one progress line already says the agent is working, and a
124+
canvas here would restart on every revision of the card. */}
117125
<div className="flex flex-wrap items-center gap-2">
118126
<VerdictBadge verdict={hypothesis.verdict}>
119127
{VERDICT_LABELS[hypothesis.verdict]}
120128
</VerdictBadge>
121-
{hypothesis.verdict === "testing" ? <AgentSpinner size={12} /> : null}
122129
</div>
123130
<p className="text-sm text-text-bright">{hypothesis.statement}</p>
124131
{hypothesis.finding ? <p className="text-xs text-text-dimmed">{hypothesis.finding}</p> : null}
@@ -191,110 +198,102 @@ export function InvestigationCard({
191198
}) {
192199
const [expanded, setExpanded] = useState(defaultExpanded);
193200
const investigation = block.investigation;
194-
const inProgress = investigation.outcome === "in_progress";
195201
const concluded = investigation.outcome === "concluded";
196202

197203
return (
198-
<div className="space-y-2">
199-
<div className="overflow-hidden rounded-lg border border-border-bright bg-background-dimmed">
200-
<div className="space-y-1.5 border-b border-grid-bright bg-background-bright px-4 py-3">
201-
<div className="flex flex-wrap items-center gap-2">
202-
<span className="text-xs font-medium text-text-dimmed">Investigation</span>
203-
<SeverityBadge severity={investigation.severity}>
204-
{SEVERITY_LABELS[investigation.severity]}
205-
</SeverityBadge>
206-
<ConfidenceBadge confidence={investigation.confidence} />
207-
</div>
208-
{/* Its own truncating line — the badge row's right corner can't hold a
209-
run id reliably at panel width (same rule as RunDiagnosisCard). */}
210-
{investigation.runId ? (
211-
<div className="truncate font-mono text-xs text-text-dimmed">{investigation.runId}</div>
212-
) : null}
204+
<div className="overflow-hidden rounded-lg border border-border-bright bg-background-dimmed">
205+
<div className="space-y-1.5 border-b border-grid-bright bg-background-bright px-4 py-3">
206+
<div className="flex flex-wrap items-center gap-2">
207+
<span className="text-xs font-medium text-text-dimmed">Investigation</span>
208+
<SeverityBadge severity={investigation.severity}>
209+
{SEVERITY_LABELS[investigation.severity]}
210+
</SeverityBadge>
211+
<ConfidenceBadge confidence={investigation.confidence} />
213212
</div>
213+
{/* Its own truncating line — the badge row's right corner can't hold a
214+
run id reliably at panel width (same rule as RunDiagnosisCard). */}
215+
{investigation.runId ? (
216+
<div className="truncate font-mono text-xs text-text-dimmed">{investigation.runId}</div>
217+
) : null}
218+
</div>
214219

215-
<div className="space-y-5 px-4 py-4">
216-
<p className="text-sm font-medium text-text-bright">{investigation.title}</p>
220+
<div className="space-y-5 px-4 py-4">
221+
<p className="text-sm font-medium text-text-bright">{investigation.title}</p>
217222

218-
<Section title={concluded ? "What happened" : "What we know"}>
219-
<p className="text-sm text-text-dimmed">{investigation.headline}</p>
220-
</Section>
223+
<Section title={concluded ? "What happened" : "What we know"}>
224+
<p className="text-sm text-text-dimmed">{investigation.headline}</p>
225+
</Section>
221226

222-
{/* A fix is only ever shown for a concluded investigation; an
227+
{/* A fix is only ever shown for a concluded investigation; an
223228
inconclusive one gets "What to check next" instead. The schema
224229
enforces the exclusivity, so this can't render both. */}
225-
{concluded && investigation.remediation ? (
226-
<Section title="How to fix">
227-
<p className="text-sm text-text-dimmed">{investigation.remediation}</p>
228-
</Section>
229-
) : null}
230+
{concluded && investigation.remediation ? (
231+
<Section title="How to fix">
232+
<p className="text-sm text-text-dimmed">{investigation.remediation}</p>
233+
</Section>
234+
) : null}
230235

231-
{investigation.checkNext && investigation.checkNext.length > 0 ? (
232-
<Section title="What to check next">
233-
<ol className="list-decimal space-y-2 pl-5">
234-
{investigation.checkNext.map((item, i) => (
235-
<li key={i} className="text-sm text-text-dimmed">
236-
{item}
237-
</li>
238-
))}
239-
</ol>
240-
</Section>
241-
) : null}
236+
{investigation.checkNext && investigation.checkNext.length > 0 ? (
237+
<Section title="What to check next">
238+
<ol className="list-decimal space-y-2 pl-5">
239+
{investigation.checkNext.map((item, i) => (
240+
<li key={i} className="text-sm text-text-dimmed">
241+
{item}
242+
</li>
243+
))}
244+
</ol>
245+
</Section>
246+
) : null}
242247

243-
{investigation.caveat ? (
244-
<Callout variant="warning">{investigation.caveat.message}</Callout>
245-
) : null}
248+
{investigation.caveat ? (
249+
<Callout variant="warning">{investigation.caveat.message}</Callout>
250+
) : null}
246251

247-
<div className="space-y-4 border-t border-grid-bright pt-4">
248-
<Button
249-
variant="minimal/small"
250-
onClick={() => setExpanded((v) => !v)}
251-
LeadingIcon={expanded ? ChevronDownIcon : ChevronRightIcon}
252-
aria-expanded={expanded}
253-
>
254-
<span className="flex items-center gap-1.5 text-xs text-text-dimmed">
255-
{expanded ? "Hide how I worked this out" : "How I worked this out"}
256-
<span className="text-text-faint">
257-
({investigation.hypotheses.length} hypothes
258-
{investigation.hypotheses.length === 1 ? "is" : "es"})
259-
</span>
252+
<div className="space-y-4 border-t border-grid-bright pt-4">
253+
<Button
254+
variant="minimal/small"
255+
onClick={() => setExpanded((v) => !v)}
256+
LeadingIcon={expanded ? ChevronDownIcon : ChevronRightIcon}
257+
aria-expanded={expanded}
258+
>
259+
<span className="flex items-center gap-1.5 text-xs text-text-dimmed">
260+
{expanded ? "Hide how I worked this out" : "How I worked this out"}
261+
<span className="text-text-faint">
262+
({investigation.hypotheses.length} hypothes
263+
{investigation.hypotheses.length === 1 ? "is" : "es"})
260264
</span>
261-
</Button>
265+
</span>
266+
</Button>
267+
268+
{expanded ? (
269+
<div className="space-y-5 pt-1">
270+
<Section title="Hypotheses">
271+
<ul className="space-y-5">
272+
{investigation.hypotheses.map((hypothesis) => (
273+
<HypothesisRow
274+
key={hypothesis.id}
275+
hypothesis={hypothesis}
276+
resolveUri={resolveUri}
277+
/>
278+
))}
279+
</ul>
280+
</Section>
262281

263-
{expanded ? (
264-
<div className="space-y-5 pt-1">
265-
<Section title="Hypotheses">
266-
<ul className="space-y-5">
267-
{investigation.hypotheses.map((hypothesis) => (
268-
<HypothesisRow
269-
key={hypothesis.id}
270-
hypothesis={hypothesis}
271-
resolveUri={resolveUri}
272-
/>
282+
{investigation.evidence.length > 0 ? (
283+
<Section title="Evidence">
284+
<ul className="space-y-3">
285+
{investigation.evidence.map((evidence, i) => (
286+
<EvidenceItem key={i} evidence={evidence} resolveUri={resolveUri} />
273287
))}
274288
</ul>
275289
</Section>
276-
277-
{investigation.evidence.length > 0 ? (
278-
<Section title="Evidence">
279-
<ul className="space-y-3">
280-
{investigation.evidence.map((evidence, i) => (
281-
<EvidenceItem key={i} evidence={evidence} resolveUri={resolveUri} />
282-
))}
283-
</ul>
284-
</Section>
285-
) : null}
286-
</div>
287-
) : null}
288-
</div>
289-
290-
<InvestigationActions actions={block.capabilities?.actions ?? []} onIntent={onIntent} />
290+
) : null}
291+
</div>
292+
) : null}
291293
</div>
294+
295+
<InvestigationActions actions={block.capabilities?.actions ?? []} onIntent={onIntent} />
292296
</div>
293-
{/* Progress lives outside the card, on the left — the same line the chat
294-
uses for in-flight tools — the same pill, so the transcript never
295-
shows two spinner styles at once. It carries the transcript's
296-
alignment itself, lining up with the card above it. */}
297-
{inProgress ? <ChatPendingTool label={investigation.progress ?? "Working…"} /> : null}
298297
</div>
299298
);
300299
}

0 commit comments

Comments
 (0)