Skip to content

Commit 7608d38

Browse files
authored
improve(ui): flatten chat tool-call rows into scannable list (#99763)
Tool calls in Control UI chat rendered as triple-nested cards: activity group card + tool-shell bubble chrome + gradient summary pill with a 0 8px 22px drop shadow. Collapsed rows now render as flat single-line rows with ellipsis, the activity group uses a flat header plus a left rule, and expanded detail keeps soft tinted blocks without card chrome. Also deletes the dead legacy .chat-tool-card__details/__output CSS and the unused renderToolCardSidebar path with its never-taken renderToolDataBlock branches. Closes #99760
1 parent 3d64efb commit 7608d38

5 files changed

Lines changed: 415 additions & 1063 deletions

File tree

ui/src/pages/chat/components/chat-tool-cards.test.ts

Lines changed: 3 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,7 @@ import { describe, expect, it, vi } from "vitest";
66
vi.mock("../markdown.ts", async (importOriginal) => await importOriginal());
77
vi.mock("../tool-display.ts", () => ({
88
formatToolDetail: (display: { detail?: string }) => display.detail,
9-
resolveToolDisplay: ({
10-
name,
11-
args,
12-
}: {
13-
name: string;
14-
args?: unknown;
15-
}) => {
9+
resolveToolDisplay: ({ name, args }: { name: string; args?: unknown }) => {
1610
const labels: Record<string, string> = {
1711
sessions_spawn: "Sub-agent",
1812
skill_workshop: "Skill Workshop",
@@ -39,7 +33,7 @@ import {
3933
formatCollapsedToolSummaryText,
4034
isToolErrorOutput,
4135
} from "../../../lib/chat/tool-cards.ts";
42-
import { renderToolCard, renderToolCardSidebar } from "./chat-tool-cards.ts";
36+
import { renderToolCard } from "./chat-tool-cards.ts";
4337

4438
function requireFirstMockArg(
4539
mock: ReturnType<typeof vi.fn>,
@@ -106,7 +100,6 @@ describe("tool-cards", () => {
106100
expect(blocks[0]?.querySelector("code")?.textContent).toBe(
107101
'{\n "mode": "session",\n "thread": true\n}',
108102
);
109-
expect(container.querySelector(".chat-tool-card__block-empty")).toBeNull();
110103
});
111104

112105
it("labels collapsed tool calls with the display summary", () => {
@@ -461,7 +454,7 @@ describe("tool-cards", () => {
461454
const summaryButton = container.querySelector("button.chat-tool-msg-summary");
462455
expect(summaryButton?.classList.contains("chat-tool-msg-summary--error")).toBe(true);
463456
expect(container.querySelector(".chat-tool-msg-summary__error-badge")).not.toBeNull();
464-
const expandedCard = container.querySelector(".chat-tool-card--expanded");
457+
const expandedCard = container.querySelector(".chat-tool-card");
465458
expect(expandedCard?.classList.contains("chat-tool-card--error")).toBe(true);
466459
expect(container.querySelector(".chat-tool-card__status-badge")).not.toBeNull();
467460
});
@@ -551,71 +544,6 @@ describe("tool-cards", () => {
551544
expect(container.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull();
552545
});
553546

554-
it("does not render View with a checkmark for sidebar cards whose output is an error JSON", () => {
555-
const container = document.createElement("div");
556-
render(
557-
renderToolCardSidebar(
558-
{
559-
id: "msg:err:sidebar",
560-
name: "web_search",
561-
outputText: JSON.stringify({
562-
error: "missing_brave_api_key",
563-
message: "BRAVE_API_KEY is not configured",
564-
}),
565-
},
566-
vi.fn(),
567-
),
568-
container,
569-
);
570-
571-
const card = container.querySelector(".chat-tool-card");
572-
const action = container.querySelector(".chat-tool-card__action");
573-
expect(card?.classList.contains("chat-tool-card--error")).toBe(true);
574-
expect(action?.classList.contains("chat-tool-card__action--error")).toBe(true);
575-
expect(action?.textContent).toContain("View error");
576-
expect(action?.textContent).not.toContain("✓");
577-
});
578-
579-
it("marks Tool not found sidebar output as an error instead of View with a checkmark", () => {
580-
const container = document.createElement("div");
581-
render(
582-
renderToolCardSidebar(
583-
{
584-
id: "msg:err:sidebar-tool-not-found",
585-
name: "Unknown",
586-
outputText: "Tool not found",
587-
},
588-
vi.fn(),
589-
),
590-
container,
591-
);
592-
593-
const action = container.querySelector(".chat-tool-card__action");
594-
expect(container.querySelector(".chat-tool-card--error")).not.toBeNull();
595-
expect(action?.textContent).toContain("View error");
596-
expect(action?.textContent).not.toContain("✓");
597-
});
598-
599-
it("marks status-only sidebar output as an error instead of View with a checkmark", () => {
600-
const container = document.createElement("div");
601-
render(
602-
renderToolCardSidebar(
603-
{
604-
id: "msg:err:sidebar-status",
605-
name: "sessions_wait",
606-
outputText: JSON.stringify({ status: "timeout" }),
607-
},
608-
vi.fn(),
609-
),
610-
container,
611-
);
612-
613-
const action = container.querySelector(".chat-tool-card__action");
614-
expect(container.querySelector(".chat-tool-card--error")).not.toBeNull();
615-
expect(action?.textContent).toContain("View error");
616-
expect(action?.textContent).not.toContain("✓");
617-
});
618-
619547
it("keeps Tool output labelling for successful results", () => {
620548
const container = document.createElement("div");
621549
render(

ui/src/pages/chat/components/chat-tool-cards.ts

Lines changed: 7 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ import {
2121
} from "../../../lib/chat/tool-display.ts";
2222
import type { SidebarContent } from "./chat-sidebar.ts";
2323

24-
const TOOL_PREVIEW_MAX_LINES = 2;
25-
const TOOL_PREVIEW_MAX_CHARS = 100;
24+
type FullMessageRequest = NonNullable<SidebarContent["fullMessageRequest"]>;
2625

2726
function formatToolOutputForSidebar(text: string): string {
2827
if (isMarkdownBlockArtText(text)) {
@@ -40,18 +39,6 @@ function formatToolOutputForSidebar(text: string): string {
4039
return text;
4140
}
4241

43-
function getTruncatedPreview(text: string): string {
44-
const allLines = text.split("\n");
45-
const lines = allLines.slice(0, TOOL_PREVIEW_MAX_LINES);
46-
const preview = lines.join("\n");
47-
if (preview.length > TOOL_PREVIEW_MAX_CHARS) {
48-
return `${preview.slice(0, TOOL_PREVIEW_MAX_CHARS)}…`;
49-
}
50-
return lines.length < allLines.length ? `${preview}…` : preview;
51-
}
52-
53-
type FullMessageRequest = NonNullable<SidebarContent["fullMessageRequest"]>;
54-
5542
function renderToolIcon(name: string) {
5643
return icons[name as IconName] ?? icons.puzzle;
5744
}
@@ -243,39 +230,22 @@ export function renderRawOutputToggle(text: string) {
243230
<span class="chat-tool-card__raw-toggle-icon">${icons.chevronDown}</span>
244231
</button>
245232
<div class="chat-tool-card__raw-body" hidden>
246-
${renderToolDataBlock({
247-
label: "Tool output",
248-
text,
249-
expanded: true,
250-
})}
233+
${renderToolDataBlock({ label: "Tool output", text })}
251234
</div>
252235
</div>
253236
`;
254237
}
255238

256-
function renderToolDataBlock(params: {
257-
label: string;
258-
text: string;
259-
expanded: boolean;
260-
empty?: boolean;
261-
}) {
262-
const { label, text, expanded, empty } = params;
239+
function renderToolDataBlock(params: { label: string; text: string }) {
240+
const { label, text } = params;
263241
const codeClass = isMarkdownBlockArtText(text) ? "markdown-block-art" : "";
264242
return html`
265-
<div class="chat-tool-card__block ${expanded ? "chat-tool-card__block--expanded" : ""}">
243+
<div class="chat-tool-card__block">
266244
<div class="chat-tool-card__block-header">
267245
<span class="chat-tool-card__block-icon">${icons.zap}</span>
268246
<span class="chat-tool-card__block-label">${label}</span>
269247
</div>
270-
${empty
271-
? html`<div class="chat-tool-card__block-empty muted">${text}</div>`
272-
: expanded
273-
? html`<pre
274-
class="chat-tool-card__block-content"
275-
><code class=${codeClass}>${text}</code></pre>`
276-
: html`<div class="chat-tool-card__block-preview mono">
277-
${getTruncatedPreview(text)}
278-
</div>`}
248+
<pre class="chat-tool-card__block-content"><code class=${codeClass}>${text}</code></pre>
279249
</div>
280250
`;
281251
}
@@ -437,7 +407,7 @@ export function renderExpandedToolCardContent(
437407
: nothing;
438408

439409
return html`
440-
<div class="chat-tool-card chat-tool-card--expanded ${isError ? "chat-tool-card--error" : ""}">
410+
<div class="chat-tool-card ${isError ? "chat-tool-card--error" : ""}">
441411
<div class="chat-tool-card__header">
442412
<div class="chat-tool-card__title">
443413
<span class="chat-tool-card__icon">${renderToolIcon(display.icon)}</span>
@@ -470,7 +440,6 @@ export function renderExpandedToolCardContent(
470440
? renderToolDataBlock({
471441
label: "Tool input",
472442
text: card.inputText!,
473-
expanded: true,
474443
})
475444
: nothing}
476445
${hasOutput
@@ -479,110 +448,8 @@ export function renderExpandedToolCardContent(
479448
: renderToolDataBlock({
480449
label: isError ? "Tool error" : "Tool output",
481450
text: card.outputText!,
482-
expanded: true,
483451
})
484452
: nothing}
485453
</div>
486454
`;
487455
}
488-
489-
export function renderToolCardSidebar(
490-
card: ToolCard,
491-
onOpenSidebar?: (content: SidebarContent) => void,
492-
canvasPluginSurfaceUrl?: string | null,
493-
embedSandboxMode: EmbedSandboxMode = "scripts",
494-
options?: { sessionKey?: string; agentId?: string },
495-
) {
496-
const display = resolveToolDisplay({ name: card.name, args: card.args });
497-
const detail = formatToolDetail(display);
498-
const preview = card.preview;
499-
const hasText = Boolean(card.outputText?.trim());
500-
const hasPreview = Boolean(preview);
501-
const isError = isToolCardError(card);
502-
const fullMessageRequest = buildToolSidebarFullMessageRequest(card, options?.sessionKey);
503-
const sidebarContent =
504-
preview?.kind === "canvas"
505-
? buildPreviewSidebarContent(preview, card.outputText, { fullMessageRequest })
506-
: buildSidebarContent(buildToolCardSidebarContent(card), {
507-
fullMessageRequest,
508-
rawText: card.outputText ?? null,
509-
});
510-
const actionContent =
511-
sidebarContent ??
512-
buildSidebarContent(buildToolCardSidebarContent(card), {
513-
fullMessageRequest,
514-
rawText: card.outputText ?? null,
515-
});
516-
const canClick = Boolean(onOpenSidebar);
517-
const handleClick = canClick ? () => onOpenSidebar?.(actionContent) : undefined;
518-
const isShort = hasText && !hasPreview && (card.outputText?.length ?? 0) <= 240;
519-
const showCollapsed = hasText && !hasPreview && !isShort;
520-
const showInline = hasText && !hasPreview && isShort;
521-
const isEmpty = !hasText && !hasPreview;
522-
const statusIcon = isError ? icons.x : icons.check;
523-
524-
return html`
525-
<div
526-
class="chat-tool-card ${canClick ? "chat-tool-card--clickable" : ""} ${isError
527-
? "chat-tool-card--error"
528-
: ""}"
529-
@click=${handleClick}
530-
role=${canClick ? "button" : nothing}
531-
tabindex=${canClick ? "0" : nothing}
532-
@keydown=${canClick
533-
? (e: KeyboardEvent) => {
534-
if (e.key !== "Enter" && e.key !== " ") {
535-
return;
536-
}
537-
e.preventDefault();
538-
handleClick?.();
539-
}
540-
: nothing}
541-
>
542-
<div class="chat-tool-card__header">
543-
<div class="chat-tool-card__title">
544-
<span class="chat-tool-card__icon">${renderToolIcon(display.icon)}</span>
545-
<span>${display.label}</span>
546-
</div>
547-
${canClick
548-
? html`<span
549-
class="chat-tool-card__action ${isError ? "chat-tool-card__action--error" : ""}"
550-
>${isError ? "View error" : hasText || hasPreview ? "View" : ""} ${statusIcon}</span
551-
>`
552-
: nothing}
553-
${isEmpty && !canClick
554-
? html`<span
555-
class="chat-tool-card__status ${isError ? "chat-tool-card__status--error" : ""}"
556-
>${statusIcon}</span
557-
>`
558-
: nothing}
559-
</div>
560-
${detail ? html`<div class="chat-tool-card__detail">${detail}</div>` : nothing}
561-
${isEmpty
562-
? html`<div
563-
class="chat-tool-card__status-text ${isError
564-
? "chat-tool-card__status-text--error"
565-
: "muted"}"
566-
>
567-
${isError ? "Failed" : "Completed"}
568-
</div>`
569-
: nothing}
570-
${preview
571-
? html`${renderToolPreview(preview, "chat_tool", {
572-
onOpenSidebar,
573-
rawText: card.outputText,
574-
canvasPluginSurfaceUrl,
575-
embedSandboxMode,
576-
})}`
577-
: nothing}
578-
${showCollapsed
579-
? html`<div class="chat-tool-card__preview mono">
580-
${getTruncatedPreview(card.outputText!)}
581-
</div>`
582-
: nothing}
583-
${showInline
584-
? html`<div class="chat-tool-card__inline mono">${card.outputText}</div>`
585-
: nothing}
586-
</div>
587-
`;
588-
}

ui/src/styles/chat/grouped.css

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -225,15 +225,15 @@ img.chat-avatar {
225225
word-wrap: break-word;
226226
}
227227

228-
/* Width-only shell: bubble chrome (border/background/padding) comes from
229-
.chat-bubble above. */
228+
/* Tool messages render flat rows, not bubbles: the shell only constrains
229+
width; all bubble chrome (border/background/padding) is reset. */
230230
.chat-bubble--tool-shell {
231231
align-self: stretch;
232232
width: min(100%, 760px);
233-
}
234-
235-
.chat-bubble--tool-shell:hover {
233+
padding: 0;
234+
border: 0;
236235
background: transparent;
236+
box-shadow: none;
237237
}
238238

239239
.chat-bubble.has-copy {
@@ -381,10 +381,6 @@ img.chat-avatar {
381381
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);
382382
}
383383

384-
.chat-bubble--tool-shell:hover {
385-
background: transparent;
386-
}
387-
388384
/* User bubbles have different styling */
389385
.chat-group.user .chat-bubble {
390386
background: var(--accent-subtle);
@@ -405,6 +401,8 @@ img.chat-avatar {
405401
}
406402

407403
:root[data-theme-mode="light"] .chat-bubble--tool-shell {
404+
border-color: transparent;
405+
background: transparent;
408406
box-shadow: none;
409407
}
410408

0 commit comments

Comments
 (0)