mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
3d64efbd3d
commit
7608d38597
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+377
-762
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
=========================================== */
|
||||
|
||||
Reference in New Issue
Block a user