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
This commit is contained in:
Peter Steinberger
2026-07-04 19:42:59 -04:00
committed by GitHub
parent 3d64efbd3d
commit 7608d38597
5 changed files with 394 additions and 1042 deletions
@@ -6,13 +6,7 @@ import { describe, expect, it, vi } from "vitest";
vi.mock("../markdown.ts", async (importOriginal) => await importOriginal());
vi.mock("../tool-display.ts", () => ({
formatToolDetail: (display: { detail?: string }) => display.detail,
resolveToolDisplay: ({
name,
args,
}: {
name: string;
args?: unknown;
}) => {
resolveToolDisplay: ({ name, args }: { name: string; args?: unknown }) => {
const labels: Record<string, string> = {
sessions_spawn: "Sub-agent",
skill_workshop: "Skill Workshop",
@@ -39,7 +33,7 @@ import {
formatCollapsedToolSummaryText,
isToolErrorOutput,
} from "../../../lib/chat/tool-cards.ts";
import { renderToolCard, renderToolCardSidebar } from "./chat-tool-cards.ts";
import { renderToolCard } from "./chat-tool-cards.ts";
function requireFirstMockArg(
mock: ReturnType<typeof vi.fn>,
@@ -106,7 +100,6 @@ describe("tool-cards", () => {
expect(blocks[0]?.querySelector("code")?.textContent).toBe(
'{\n "mode": "session",\n "thread": true\n}',
);
expect(container.querySelector(".chat-tool-card__block-empty")).toBeNull();
});
it("labels collapsed tool calls with the display summary", () => {
@@ -461,7 +454,7 @@ describe("tool-cards", () => {
const summaryButton = container.querySelector("button.chat-tool-msg-summary");
expect(summaryButton?.classList.contains("chat-tool-msg-summary--error")).toBe(true);
expect(container.querySelector(".chat-tool-msg-summary__error-badge")).not.toBeNull();
const expandedCard = container.querySelector(".chat-tool-card--expanded");
const expandedCard = container.querySelector(".chat-tool-card");
expect(expandedCard?.classList.contains("chat-tool-card--error")).toBe(true);
expect(container.querySelector(".chat-tool-card__status-badge")).not.toBeNull();
});
@@ -551,71 +544,6 @@ describe("tool-cards", () => {
expect(container.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull();
});
it("does not render View with a checkmark for sidebar cards whose output is an error JSON", () => {
const container = document.createElement("div");
render(
renderToolCardSidebar(
{
id: "msg:err:sidebar",
name: "web_search",
outputText: JSON.stringify({
error: "missing_brave_api_key",
message: "BRAVE_API_KEY is not configured",
}),
},
vi.fn(),
),
container,
);
const card = container.querySelector(".chat-tool-card");
const action = container.querySelector(".chat-tool-card__action");
expect(card?.classList.contains("chat-tool-card--error")).toBe(true);
expect(action?.classList.contains("chat-tool-card__action--error")).toBe(true);
expect(action?.textContent).toContain("View error");
expect(action?.textContent).not.toContain("✓");
});
it("marks Tool not found sidebar output as an error instead of View with a checkmark", () => {
const container = document.createElement("div");
render(
renderToolCardSidebar(
{
id: "msg:err:sidebar-tool-not-found",
name: "Unknown",
outputText: "Tool not found",
},
vi.fn(),
),
container,
);
const action = container.querySelector(".chat-tool-card__action");
expect(container.querySelector(".chat-tool-card--error")).not.toBeNull();
expect(action?.textContent).toContain("View error");
expect(action?.textContent).not.toContain("✓");
});
it("marks status-only sidebar output as an error instead of View with a checkmark", () => {
const container = document.createElement("div");
render(
renderToolCardSidebar(
{
id: "msg:err:sidebar-status",
name: "sessions_wait",
outputText: JSON.stringify({ status: "timeout" }),
},
vi.fn(),
),
container,
);
const action = container.querySelector(".chat-tool-card__action");
expect(container.querySelector(".chat-tool-card--error")).not.toBeNull();
expect(action?.textContent).toContain("View error");
expect(action?.textContent).not.toContain("✓");
});
it("keeps Tool output labelling for successful results", () => {
const container = document.createElement("div");
render(
+7 -140
View File
@@ -21,8 +21,7 @@ import {
} from "../../../lib/chat/tool-display.ts";
import type { SidebarContent } from "./chat-sidebar.ts";
const TOOL_PREVIEW_MAX_LINES = 2;
const TOOL_PREVIEW_MAX_CHARS = 100;
type FullMessageRequest = NonNullable<SidebarContent["fullMessageRequest"]>;
function formatToolOutputForSidebar(text: string): string {
if (isMarkdownBlockArtText(text)) {
@@ -40,18 +39,6 @@ function formatToolOutputForSidebar(text: string): string {
return text;
}
function getTruncatedPreview(text: string): string {
const allLines = text.split("\n");
const lines = allLines.slice(0, TOOL_PREVIEW_MAX_LINES);
const preview = lines.join("\n");
if (preview.length > TOOL_PREVIEW_MAX_CHARS) {
return `${preview.slice(0, TOOL_PREVIEW_MAX_CHARS)}`;
}
return lines.length < allLines.length ? `${preview}` : preview;
}
type FullMessageRequest = NonNullable<SidebarContent["fullMessageRequest"]>;
function renderToolIcon(name: string) {
return icons[name as IconName] ?? icons.puzzle;
}
@@ -243,39 +230,22 @@ export function renderRawOutputToggle(text: string) {
<span class="chat-tool-card__raw-toggle-icon">${icons.chevronDown}</span>
</button>
<div class="chat-tool-card__raw-body" hidden>
${renderToolDataBlock({
label: "Tool output",
text,
expanded: true,
})}
${renderToolDataBlock({ label: "Tool output", text })}
</div>
</div>
`;
}
function renderToolDataBlock(params: {
label: string;
text: string;
expanded: boolean;
empty?: boolean;
}) {
const { label, text, expanded, empty } = params;
function renderToolDataBlock(params: { label: string; text: string }) {
const { label, text } = params;
const codeClass = isMarkdownBlockArtText(text) ? "markdown-block-art" : "";
return html`
<div class="chat-tool-card__block ${expanded ? "chat-tool-card__block--expanded" : ""}">
<div class="chat-tool-card__block">
<div class="chat-tool-card__block-header">
<span class="chat-tool-card__block-icon">${icons.zap}</span>
<span class="chat-tool-card__block-label">${label}</span>
</div>
${empty
? html`<div class="chat-tool-card__block-empty muted">${text}</div>`
: expanded
? html`<pre
class="chat-tool-card__block-content"
><code class=${codeClass}>${text}</code></pre>`
: html`<div class="chat-tool-card__block-preview mono">
${getTruncatedPreview(text)}
</div>`}
<pre class="chat-tool-card__block-content"><code class=${codeClass}>${text}</code></pre>
</div>
`;
}
@@ -437,7 +407,7 @@ export function renderExpandedToolCardContent(
: nothing;
return html`
<div class="chat-tool-card chat-tool-card--expanded ${isError ? "chat-tool-card--error" : ""}">
<div class="chat-tool-card ${isError ? "chat-tool-card--error" : ""}">
<div class="chat-tool-card__header">
<div class="chat-tool-card__title">
<span class="chat-tool-card__icon">${renderToolIcon(display.icon)}</span>
@@ -470,7 +440,6 @@ export function renderExpandedToolCardContent(
? renderToolDataBlock({
label: "Tool input",
text: card.inputText!,
expanded: true,
})
: nothing}
${hasOutput
@@ -479,110 +448,8 @@ export function renderExpandedToolCardContent(
: renderToolDataBlock({
label: isError ? "Tool error" : "Tool output",
text: card.outputText!,
expanded: true,
})
: nothing}
</div>
`;
}
export function renderToolCardSidebar(
card: ToolCard,
onOpenSidebar?: (content: SidebarContent) => void,
canvasPluginSurfaceUrl?: string | null,
embedSandboxMode: EmbedSandboxMode = "scripts",
options?: { sessionKey?: string; agentId?: string },
) {
const display = resolveToolDisplay({ name: card.name, args: card.args });
const detail = formatToolDetail(display);
const preview = card.preview;
const hasText = Boolean(card.outputText?.trim());
const hasPreview = Boolean(preview);
const isError = isToolCardError(card);
const fullMessageRequest = buildToolSidebarFullMessageRequest(card, options?.sessionKey);
const sidebarContent =
preview?.kind === "canvas"
? buildPreviewSidebarContent(preview, card.outputText, { fullMessageRequest })
: buildSidebarContent(buildToolCardSidebarContent(card), {
fullMessageRequest,
rawText: card.outputText ?? null,
});
const actionContent =
sidebarContent ??
buildSidebarContent(buildToolCardSidebarContent(card), {
fullMessageRequest,
rawText: card.outputText ?? null,
});
const canClick = Boolean(onOpenSidebar);
const handleClick = canClick ? () => onOpenSidebar?.(actionContent) : undefined;
const isShort = hasText && !hasPreview && (card.outputText?.length ?? 0) <= 240;
const showCollapsed = hasText && !hasPreview && !isShort;
const showInline = hasText && !hasPreview && isShort;
const isEmpty = !hasText && !hasPreview;
const statusIcon = isError ? icons.x : icons.check;
return html`
<div
class="chat-tool-card ${canClick ? "chat-tool-card--clickable" : ""} ${isError
? "chat-tool-card--error"
: ""}"
@click=${handleClick}
role=${canClick ? "button" : nothing}
tabindex=${canClick ? "0" : nothing}
@keydown=${canClick
? (e: KeyboardEvent) => {
if (e.key !== "Enter" && e.key !== " ") {
return;
}
e.preventDefault();
handleClick?.();
}
: nothing}
>
<div class="chat-tool-card__header">
<div class="chat-tool-card__title">
<span class="chat-tool-card__icon">${renderToolIcon(display.icon)}</span>
<span>${display.label}</span>
</div>
${canClick
? html`<span
class="chat-tool-card__action ${isError ? "chat-tool-card__action--error" : ""}"
>${isError ? "View error" : hasText || hasPreview ? "View" : ""} ${statusIcon}</span
>`
: nothing}
${isEmpty && !canClick
? html`<span
class="chat-tool-card__status ${isError ? "chat-tool-card__status--error" : ""}"
>${statusIcon}</span
>`
: nothing}
</div>
${detail ? html`<div class="chat-tool-card__detail">${detail}</div>` : nothing}
${isEmpty
? html`<div
class="chat-tool-card__status-text ${isError
? "chat-tool-card__status-text--error"
: "muted"}"
>
${isError ? "Failed" : "Completed"}
</div>`
: nothing}
${preview
? html`${renderToolPreview(preview, "chat_tool", {
onOpenSidebar,
rawText: card.outputText,
canvasPluginSurfaceUrl,
embedSandboxMode,
})}`
: nothing}
${showCollapsed
? html`<div class="chat-tool-card__preview mono">
${getTruncatedPreview(card.outputText!)}
</div>`
: nothing}
${showInline
? html`<div class="chat-tool-card__inline mono">${card.outputText}</div>`
: nothing}
</div>
`;
}
+7 -9
View File
@@ -225,15 +225,15 @@ img.chat-avatar {
word-wrap: break-word;
}
/* Width-only shell: bubble chrome (border/background/padding) comes from
.chat-bubble above. */
/* Tool messages render flat rows, not bubbles: the shell only constrains
width; all bubble chrome (border/background/padding) is reset. */
.chat-bubble--tool-shell {
align-self: stretch;
width: min(100%, 760px);
}
.chat-bubble--tool-shell:hover {
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.chat-bubble.has-copy {
@@ -381,10 +381,6 @@ img.chat-avatar {
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);
}
.chat-bubble--tool-shell:hover {
background: transparent;
}
/* User bubbles have different styling */
.chat-group.user .chat-bubble {
background: var(--accent-subtle);
@@ -405,6 +401,8 @@ img.chat-avatar {
}
:root[data-theme-mode="light"] .chat-bubble--tool-shell {
border-color: transparent;
background: transparent;
box-shadow: none;
}
File diff suppressed because it is too large Load Diff
-56
View File
@@ -3956,62 +3956,6 @@ td.data-table-key-col {
}
}
/* Tool cards: canonical card/title/detail styles live in chat/tool-cards.css;
the details/summary/output rules below have no counterpart there. */
.chat-tool-card__details {
margin-top: 6px;
}
.chat-tool-card__summary {
font-family: var(--mono);
font-size: 12px; /* was 11px */
color: var(--muted);
cursor: pointer;
list-style: none;
display: inline-flex;
align-items: center;
gap: 6px;
}
.chat-tool-card__summary::-webkit-details-marker {
display: none;
}
.chat-tool-card__summary-meta {
color: var(--muted);
opacity: 0.7;
}
.chat-tool-card__details[open] .chat-tool-card__summary {
color: var(--text);
}
.chat-tool-card__output {
margin-top: 8px;
font-family: var(--mono);
font-size: 12px; /* was 11px */
line-height: 1.5;
white-space: pre-wrap;
color: var(--chat-text);
padding: 8px 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--card);
}
:root[data-theme-mode="light"] .chat-tool-card__output {
background: var(--bg);
}
.chat-stamp {
font-size: 12px; /* was 11px */
color: var(--muted);
}
.chat-line.user .chat-stamp {
text-align: right;
}
/* ===========================================
QR Code
=========================================== */