Skip to content

Commit 1d15fe3

Browse files
committed
feat(webapp): pending tool pill replaces streamed input JSON in the chat
An in-flight tool call now shows a compact pill (per-tool phrase + spinner, watch-chip visual language) instead of raw streaming JSON — covering render_view and get_report, the biggest offenders. Completed and error states render as before; gallery in-flight examples reworked and a pill line-up section added.
1 parent fe2688a commit 1d15fe3

10 files changed

Lines changed: 174 additions & 35 deletions

File tree

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

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,20 @@ import { memo } from "react";
66
import { Button, LinkButton } from "~/components/primitives/Buttons";
77
import { Callout } from "~/components/primitives/Callout";
88
import { renderPart, toSafeUrl } from "~/components/runs/v3/agent/AgentMessageView";
9-
import { ToolUseRow } from "~/components/runs/v3/ai/AIChatMessages";
109
import { sameOriginPath } from "./navigate-target";
1110
import { hasToolProgressLine, IN_FLIGHT_TOOL_STATES } from "./progress-line";
1211
import { useTranscriptAutoScroll } from "./useTranscriptAutoScroll";
1312
import {
1413
ChatActionsRow,
1514
ChatCardSlot,
15+
ChatPendingTool,
1616
ChatProgress,
1717
ChatText,
18-
ChatToolRow,
1918
ChatTranscript,
2019
ChatTurn,
2120
ChatWakeSlot,
2221
} from "./chat-layout";
22+
import { toolPendingLabel } from "./tool-labels";
2323
import { reportBlockFromToolPart } from "./report-block-adapter";
2424
import type { ResolvedUri } from "./ReportView";
2525
import { ViewBlocks } from "./view-catalog";
@@ -85,8 +85,9 @@ function viewSpecFor(part: UIMessage["parts"][number]): { blocks: unknown[] } |
8585
* it retyped.
8686
*
8787
* Both replace the generic tool row: a rendered card already says everything the
88-
* raw JSON would. A `get_report` part that can't be adapted (still streaming, or
89-
* an error) returns null and keeps its tool row, so the failure stays visible.
88+
* raw JSON would. A `get_report` part that can't be adapted returns null and
89+
* falls through — to the pending pill while it is still streaming, to the tool row
90+
* once it has failed, so the failure stays visible.
9091
*/
9192
function blocksFor(part: UIMessage["parts"][number]): unknown[] | null {
9293
const spec = viewSpecFor(part);
@@ -105,9 +106,12 @@ function blocksFor(part: UIMessage["parts"][number]): unknown[] | null {
105106
* Everything the panel styles itself is handled here; the rest falls through to
106107
* the shared `renderPart` so agent output still looks the same across the app.
107108
* The differences: text is always the rendered markdown (no raw toggle) at the
108-
* dashboard's default size, and an in-flight tool row is static with the spinner
109-
* on a separate line. Citations are handled a level up, where a run of them can
110-
* be grouped into one row.
109+
* dashboard's default size, and an in-flight tool call is a pending pill rather
110+
* than a tool row streaming its input JSON — the input is the agent's business,
111+
* and watching it arrive character by character only to have it replaced by a
112+
* card is noise. Once the call lands, the part renders exactly as it always did.
113+
* Citations are handled a level up, where a run of them can be grouped into one
114+
* row.
111115
*/
112116
function renderDashboardPart(part: UIMessage["parts"][number], i: number) {
113117
const p = part as {
@@ -116,8 +120,6 @@ function renderDashboardPart(part: UIMessage["parts"][number], i: number) {
116120
url?: string;
117121
title?: string;
118122
state?: string;
119-
input?: unknown;
120-
toolCallId?: string;
121123
};
122124
const type = part.type as string;
123125

@@ -126,19 +128,7 @@ function renderDashboardPart(part: UIMessage["parts"][number], i: number) {
126128
}
127129

128130
if (type.startsWith("tool-") && IN_FLIGHT_TOOL_STATES.has(p.state ?? "")) {
129-
const toolName = type.slice(5);
130-
return (
131-
<ChatToolRow key={i}>
132-
<ToolUseRow
133-
tool={{
134-
toolCallId: p.toolCallId ?? `tool-${i}`,
135-
toolName,
136-
inputJson: JSON.stringify(p.input ?? {}, null, 2),
137-
}}
138-
/>
139-
<ChatProgress>Running {toolName}</ChatProgress>
140-
</ChatToolRow>
141-
);
131+
return <ChatPendingTool key={i} label={`${toolPendingLabel(type.slice(5))}…`} />;
142132
}
143133

144134
return renderPart(part, i);

apps/webapp/app/components/dashboard-agent/chat-layout.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,11 @@ describe("chat-layout enforcement", () => {
8686
"ChatText",
8787
"ChatCardSlot",
8888
"ChatProgress",
89+
"ChatPendingTool",
8990
"ChatToolRow",
9091
"ChatNote",
9192
"ChatStatusLine",
93+
"ChatWakeSlot",
9294
"ChatActionsRow",
9395
]) {
9496
expect(source, name).toContain(`export function ${name}(`);

apps/webapp/app/components/dashboard-agent/chat-layout.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
* card (diagnosis, investigation, report, chart), a
2323
* callout, a chip row
2424
* - `ChatProgress` — a spinner and one line of progress
25+
* - `ChatPendingTool` — a tool call still in flight, as a compact pill
2526
* - `ChatToolRow` — a tool-call row, optionally with progress under it
2627
* - `ChatNote` — an inline system / interceptor note
2728
* - `ChatWakeSlot` — an unprompted turn: its banner and the narration under
@@ -65,6 +66,8 @@ const TURN_GAP = "space-y-4";
6566
const TURN_BODY_GAP = "space-y-2";
6667
/** Gap inside a single-line row (icon to text, button to button). */
6768
const ROW_GAP = "gap-2";
69+
/** Gap inside a chip (icon to label). Tighter than a row — same as a watch chip. */
70+
const CHIP_GAP = "gap-1.5";
6871
/** Rhythm inside one unit — a banner and the text it introduces. */
6972
const UNIT_GAP = "space-y-1.5";
7073

@@ -203,6 +206,33 @@ export function ChatProgress({ children }: { children: React.ReactNode }) {
203206
);
204207
}
205208

209+
/**
210+
* A tool call still in flight, as a compact pill: a spinner and one short phrase
211+
* saying what the agent is doing.
212+
*
213+
* It replaces the tool row for the whole in-flight phase, so the transcript never
214+
* shows a half-streamed blob of input JSON that then flips to a card. The pill is
215+
* deliberately the smallest thing that fits the transcript's chip language (the
216+
* watch chips are its sibling) — when the call lands, whatever the result renders
217+
* as takes its place, and the jump is one line high.
218+
*/
219+
export function ChatPendingTool({ label }: { label: string }) {
220+
const insetClass = useInsetClass();
221+
return (
222+
<div className={cn(insetClass, "flex min-w-0")}>
223+
<span
224+
className={cn(
225+
"inline-flex h-6 min-w-0 items-center rounded-full border border-border-bright bg-background-bright px-2.5 text-xs text-text-dimmed",
226+
CHIP_GAP
227+
)}
228+
>
229+
<Spinner className="size-3 shrink-0" />
230+
<span className="truncate">{label}</span>
231+
</span>
232+
</div>
233+
);
234+
}
235+
206236
/**
207237
* A tool-call row, and optionally a `ChatProgress` under it while the call is in
208238
* flight. Nothing but the row's placement lives here — the row itself is the

apps/webapp/app/components/dashboard-agent/demo/demo-chats.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -795,7 +795,7 @@ const baseToolInFlight: DemoChat = {
795795
title: "How deep is the email queue?",
796796
flow: "base",
797797
summary:
798-
"A tool row mid-call ('calling…') above a finished one. Click either row to expand its input/output.",
798+
"A pending pill for the call still in flight, under a finished tool row. Click the finished row to expand its input/output.",
799799
banner: PROD_BANNER,
800800
activity: "working",
801801
lastMessageAt: "2026-07-27T10:26:00.000Z",
@@ -821,10 +821,12 @@ const baseToolInFlight: DemoChat = {
821821
{ rows: [{ "count()": 4812 }] },
822822
"run-query-done"
823823
),
824+
// A real tool name, so the pending pill shows its real phrase rather
825+
// than the unknown-tool fallback.
824826
pendingToolPart(
825-
"get_queue_health",
827+
"get_queue",
826828
{ queue: DEMO_WORLD.queue, period: "1h" },
827-
"get-queue-health-pending"
829+
"get-queue-pending"
828830
),
829831
]),
830832
],

apps/webapp/app/components/dashboard-agent/progress-line.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
/**
22
* Which progress line the transcript shows.
33
*
4-
* There are two candidates: a tool's own line ("Running render_view…", rendered
5-
* under the in-flight tool row) and the turn's generic activity ("Thinking…" /
6-
* "Working…"). Both were showing at once. The specific line always says more, so
7-
* it wins and the generic one stands down — exactly one status line at a time.
4+
* There are two candidates: a tool's own line (the pending pill, "Rendering a
5+
* card…") and the turn's generic activity ("Thinking…" / "Working…"). Both were
6+
* showing at once. The specific line always says more, so it wins and the generic
7+
* one stands down — exactly one status line at a time.
88
*/
99

10-
/** A tool call that hasn't produced output yet, so its row carries a spinner. */
10+
/** A tool call that hasn't produced output yet, so it shows as a pending pill. */
1111
export const IN_FLIGHT_TOOL_STATES = new Set(["input-streaming", "input-available"]);
1212

1313
type ProgressMessage = { role?: string; parts?: ReadonlyArray<unknown> };

apps/webapp/app/components/dashboard-agent/report-block-adapter.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
* have different ids and therefore never collapse into one card.
1414
*
1515
* Every failure mode returns `null`. A malformed or half-streamed tool part must
16-
* degrade to "no card" (the caller then shows the raw tool row), never to a crash
17-
* or a card full of blanks.
16+
* degrade to "no card" (the caller then shows the pending pill or the raw tool
17+
* row), never to a crash or a card full of blanks.
1818
*/
1919
import {
2020
VIEW_BLOCK_VERSION,
@@ -48,8 +48,8 @@ export function reportBlockFromToolPart(part: unknown): EnvelopedReportBlock | n
4848
const p = (part ?? {}) as MaybeToolPart;
4949

5050
if (p.type !== REPORT_TOOL_PART_TYPE) return null;
51-
// Only a finished call has a snapshot. `output-error` and the in-flight states
52-
// stay as the generic tool row so the failure is visible.
51+
// Only a finished call has a snapshot. The in-flight states fall back to the
52+
// pending pill, `output-error` to the generic tool row so the failure is visible.
5353
if (p.state !== "output-available") return null;
5454
// Identity is the tool call's. Without it the block couldn't be keyed stably
5555
// across re-renders, so we'd rather render nothing than a card that remounts.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { dashboardAgentCodeToolSchemas } from "@internal/dashboard-agent/tool-schemas";
2+
import { describe, expect, it } from "vitest";
3+
import { toolPendingLabel } from "./tool-labels";
4+
5+
describe("toolPendingLabel", () => {
6+
it("names every tool the agent can call", () => {
7+
// A tool with no phrase falls back to `Running <name>`, which is the one
8+
// thing the pill should never say for a tool we ship.
9+
const unnamed = Object.keys(dashboardAgentCodeToolSchemas).filter((name) =>
10+
toolPendingLabel(name).startsWith("Running ")
11+
);
12+
// `run_query` is the exception: "Running a query" is the phrase, not a fallback.
13+
expect(unnamed).toEqual(["run_query"]);
14+
});
15+
16+
it("falls back to the tool name for a tool it doesn't know", () => {
17+
expect(toolPendingLabel("get_queue_health")).toBe("Running get_queue_health");
18+
});
19+
20+
it("reads as a phrase, not an identifier", () => {
21+
expect(toolPendingLabel("get_run")).toBe("Reading the run");
22+
expect(toolPendingLabel("render_view")).toBe("Rendering a card");
23+
// No trailing ellipsis — the pill adds it.
24+
expect(toolPendingLabel("get_report")).not.toMatch(/$/);
25+
});
26+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* What a tool call is called while it is still running.
3+
*
4+
* An in-flight tool call shows a pending pill, and the pill needs one short
5+
* phrase saying what the agent is doing — not the tool's identifier. The phrases
6+
* are written from the reader's side ("Reading the run", not "get_run"), present
7+
* tense, no trailing ellipsis: the pill adds that.
8+
*
9+
* A tool that isn't in the map keeps the old wording, `Running <name>`, so a new
10+
* tool is readable before anyone gets round to naming it here.
11+
*/
12+
13+
const TOOL_LABELS: Record<string, string> = {
14+
list_projects: "Listing projects",
15+
list_environments: "Listing environments",
16+
list_tasks: "Listing tasks",
17+
list_runs: "Looking through runs",
18+
get_run: "Reading the run",
19+
get_run_trace: "Reading the run's trace",
20+
list_errors: "Looking through errors",
21+
get_error: "Reading the error",
22+
get_query_schema: "Reading the data schema",
23+
run_query: "Running a query",
24+
ask_support: "Asking support",
25+
render_view: "Rendering a card",
26+
get_report: "Building the health report",
27+
get_queue: "Reading the queue",
28+
list_deploys: "Looking through deploys",
29+
get_deploy: "Reading the deploy",
30+
correlate_version: "Correlating versions",
31+
search_docs: "Searching the docs",
32+
get_current_page: "Reading the current page",
33+
navigate_to: "Opening the page",
34+
schedule_watch: "Setting up a watch",
35+
list_alerts: "Listing alerts",
36+
create_alert: "Creating an alert",
37+
delete_alert: "Deleting an alert",
38+
// Code mode.
39+
get_repo_info: "Reading the repo",
40+
list_files: "Listing files",
41+
read_file: "Reading a file",
42+
search_code: "Searching the code",
43+
};
44+
45+
/** The pending pill's label for a tool, by its name (no `tool-` prefix). */
46+
export function toolPendingLabel(toolName: string): string {
47+
return TOOL_LABELS[toolName] ?? `Running ${toolName}`;
48+
}

apps/webapp/app/routes/storybook.agent-ui/manifest.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,16 @@ export const MANIFEST: GallerySection[] = [
273273
group: "messages",
274274
},
275275
{ sectionId: "messages-reasoning", title: "Reasoning part", group: "messages" },
276-
{ sectionId: "messages-tool-in-flight", title: "Tool call in flight", group: "messages" },
276+
{
277+
sectionId: "messages-tool-in-flight",
278+
title: "Tool call in flight — pending pill",
279+
group: "messages",
280+
},
281+
{
282+
sectionId: "messages-tool-pending-pills",
283+
title: "Pending pills — the labels, including a card tool",
284+
group: "messages",
285+
},
277286
{
278287
sectionId: "messages-tool-expanded",
279288
title: "Tool call expanded on its output",

apps/webapp/app/routes/storybook.agent-ui/route.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,37 @@ function MessageHarness({
265265
);
266266
}
267267

268+
/**
269+
* Every pending pill next to each other, one turn per in-flight tool.
270+
*
271+
* A real turn only ever has one call in flight, so this is a label sheet rather
272+
* than a transcript: the phrasing has to hold up read as a set, and the two tools
273+
* that used to stream the most input JSON before flipping to a card (`render_view`,
274+
* `get_report`) have to look like every other wait. The last one is a tool the
275+
* label map doesn't know, showing the `Running <name>` fallback.
276+
*/
277+
const PENDING_PILL_TOOLS: { tool: string; input: unknown }[] = [
278+
{ tool: "render_view", input: { blocks: [{ type: "diagnosis" }] } },
279+
{ tool: "get_report", input: { window: "24h" } },
280+
{ tool: "get_run", input: { runId: "run_demo" } },
281+
{ tool: "run_query", input: { query: "SELECT count() FROM task_runs" } },
282+
{ tool: "search_docs", input: { query: "concurrency limits" } },
283+
{ tool: "brand_new_tool", input: {} },
284+
];
285+
286+
function PendingPillsHarness() {
287+
const messages: UIMessage[] = PENDING_PILL_TOOLS.map(({ tool, input }) =>
288+
demoFixtures.assistantMessage(`pending-${tool}`, [
289+
demoFixtures.pendingToolPart(tool, input, `pending-${tool}`),
290+
])
291+
);
292+
return (
293+
<div className="rounded-lg border border-grid-bright bg-background-bright">
294+
<DashboardAgentMessages messages={messages} activity={null} />
295+
</div>
296+
);
297+
}
298+
268299
/** The demo chart card's frame around an empty result set. */
269300
function EmptyChartCard() {
270301
return (
@@ -704,6 +735,7 @@ const STATES: Record<string, React.ReactNode> = {
704735
"messages-streaming-text": <MessageHarness chatId={demoId("base-streaming")} />,
705736
"messages-reasoning": <MessageHarness chatId={demoId("investigate-streaming")} />,
706737
"messages-tool-in-flight": <MessageHarness chatId={demoId("base-tool-in-flight")} />,
738+
"messages-tool-pending-pills": <PendingPillsHarness />,
707739
"messages-tool-expanded": <MessageHarness chatId={demoId("base-tool-in-flight")} />,
708740
"messages-error-retry": <MessageHarness chatId={demoId("base-error-retry")} withError />,
709741
// Two turns only: the resumed chat's third turn is a live `chart` block, and

0 commit comments

Comments
 (0)