Files
openclaw/ui/src/ui/chat/tool-cards.ts
Val Alexander af56926e2f Polish markdown preview chrome
Polish the Control UI markdown preview chrome and sidebar raw-text behavior.

- Add the upgraded preview dialog/sidebar chrome and tighten related CSS coverage.
- Show workspace-relative paths in the markdown preview dialog instead of absolute filesystem paths.
- Preserve raw markdown source for idempotent raw-text toggles.
- Align browser plugin-sdk facade export parity for DEFAULT_BROWSER_ACTION_TIMEOUT_MS.
- Stabilize the gateway update channel test by waiting for the async update runner call.

Validation:
- OPENCLAW_LOCAL_CHECK=0 pnpm test ui/src/ui/views/agents.test.ts ui/src/ui/views/chat.test.ts src/plugins/contracts/plugin-sdk-subpaths.test.ts src/gateway/server.roles-allowlist-update.test.ts
- OPENCLAW_LOCAL_CHECK=0 pnpm check:changed
- GitHub checks green on ebbe96fc88
2026-04-25 03:16:54 -05:00

591 lines
18 KiB
TypeScript

import { html, nothing } from "lit";
import { extractCanvasFromText } from "../../../../src/chat/canvas-render.js";
import { resolveCanvasIframeUrl } from "../canvas-url.ts";
import { resolveEmbedSandbox, type EmbedSandboxMode } from "../embed-sandbox.ts";
import { icons } from "../icons.ts";
import type { SidebarContent } from "../sidebar-content.ts";
import { formatToolDetail, resolveToolDisplay } from "../tool-display.ts";
import type { ToolCard } from "../types/chat-types.ts";
import { extractTextCached } from "./message-extract.ts";
import { isToolResultMessage } from "./message-normalizer.ts";
import { formatToolOutputForSidebar, getTruncatedPreview } from "./tool-helpers.ts";
export type ToolPreview = NonNullable<ToolCard["preview"]>;
function resolveCanvasPreviewSandbox(preview: ToolPreview): string {
return resolveEmbedSandbox(preview.kind === "canvas" ? "scripts" : "scripts");
}
function normalizeContent(content: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(content)) {
return [];
}
return content.filter(Boolean) as Array<Record<string, unknown>>;
}
function coerceArgs(value: unknown): unknown {
if (typeof value !== "string") {
return value;
}
const trimmed = value.trim();
if (!trimmed) {
return value;
}
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
return value;
}
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
function extractToolText(item: Record<string, unknown>): string | undefined {
if (typeof item.text === "string") {
return item.text;
}
if (typeof item.content === "string") {
return item.content;
}
return undefined;
}
export function extractToolPreview(
outputText: string | undefined,
toolName: string | undefined,
): ToolCard["preview"] | undefined {
return extractCanvasFromText(outputText, toolName);
}
function resolveToolCardId(
item: Record<string, unknown>,
message: Record<string, unknown>,
index: number,
prefix = "tool",
): string {
const explicitId =
(typeof item.id === "string" && item.id.trim()) ||
(typeof item.toolCallId === "string" && item.toolCallId.trim()) ||
(typeof item.tool_call_id === "string" && item.tool_call_id.trim()) ||
(typeof item.callId === "string" && item.callId.trim()) ||
(typeof message.toolCallId === "string" && message.toolCallId.trim()) ||
(typeof message.tool_call_id === "string" && message.tool_call_id.trim()) ||
"";
if (explicitId) {
return `${prefix}:${explicitId}`;
}
const name =
(typeof item.name === "string" && item.name.trim()) ||
(typeof message.toolName === "string" && message.toolName.trim()) ||
(typeof message.tool_name === "string" && message.tool_name.trim()) ||
"tool";
return `${prefix}:${name}:${index}`;
}
function serializeToolInput(args: unknown): string | undefined {
if (args === undefined || args === null) {
return undefined;
}
if (typeof args === "string") {
return args;
}
try {
return JSON.stringify(args, null, 2);
} catch {
if (typeof args === "number" || typeof args === "boolean" || typeof args === "bigint") {
return String(args);
}
if (typeof args === "symbol") {
return args.description ? `Symbol(${args.description})` : "Symbol()";
}
return Object.prototype.toString.call(args);
}
}
function formatPayloadForSidebar(
text: string | undefined,
language: "json" | "text" = "text",
): string {
if (!text?.trim()) {
return "";
}
if (language === "json") {
return `\`\`\`json
${text}
\`\`\``;
}
const formatted = formatToolOutputForSidebar(text);
if (formatted.includes("```")) {
return formatted;
}
return `\`\`\`text
${text}
\`\`\``;
}
function findLatestCard(cards: ToolCard[], id: string, name: string): ToolCard | undefined {
for (let i = cards.length - 1; i >= 0; i--) {
const card = cards[i];
if (!card) {
continue;
}
if (card.id === id || (card.name === name && !card.outputText)) {
return card;
}
}
return undefined;
}
export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[] {
const m = message as Record<string, unknown>;
const content = normalizeContent(m.content);
const cards: ToolCard[] = [];
for (let index = 0; index < content.length; index++) {
const item = content[index] ?? {};
const kind = (typeof item.type === "string" ? item.type : "").toLowerCase();
const isToolCall =
["toolcall", "tool_call", "tooluse", "tool_use"].includes(kind) ||
(typeof item.name === "string" &&
(item.arguments != null || item.args != null || item.input != null));
if (isToolCall) {
const args = coerceArgs(item.arguments ?? item.args ?? item.input);
cards.push({
id: resolveToolCardId(item, m, index, prefix),
name: (item.name as string) ?? "tool",
args,
inputText: serializeToolInput(args),
});
continue;
}
if (kind === "toolresult" || kind === "tool_result") {
const name = typeof item.name === "string" ? item.name : "tool";
const cardId = resolveToolCardId(item, m, index, prefix);
const existing = findLatestCard(cards, cardId, name);
const text = extractToolText(item);
const preview = extractToolPreview(text, name);
if (existing) {
existing.outputText = text;
existing.preview = preview;
continue;
}
cards.push({
id: cardId,
name,
outputText: text,
preview,
});
}
}
const role = typeof m.role === "string" ? m.role.toLowerCase() : "";
const isStandaloneToolMessage =
isToolResultMessage(message) ||
role === "tool" ||
role === "function" ||
typeof m.toolName === "string" ||
typeof m.tool_name === "string";
if (isStandaloneToolMessage && cards.length === 0) {
const name =
(typeof m.toolName === "string" && m.toolName) ||
(typeof m.tool_name === "string" && m.tool_name) ||
"tool";
const text = extractTextCached(message) ?? undefined;
cards.push({
id: resolveToolCardId({}, m, 0, prefix),
name,
outputText: text,
preview: extractToolPreview(text, name),
});
}
return cards;
}
export function buildToolCardSidebarContent(card: ToolCard): string {
const display = resolveToolDisplay({ name: card.name, args: card.args });
const detail = formatToolDetail(display);
const sections = [`## ${display.label}`, `**Tool:** \`${display.name}\``];
if (detail) {
sections.push(`**Summary:** ${detail}`);
}
if (card.inputText?.trim()) {
const inputIsJson = typeof card.args === "object" && card.args !== null;
sections.push(
`### Tool input\n${formatPayloadForSidebar(card.inputText, inputIsJson ? "json" : "text")}`,
);
}
if (card.outputText?.trim()) {
sections.push(`### Tool output\n${formatToolOutputForSidebar(card.outputText)}`);
} else {
sections.push(`### Tool output\n*No output — tool completed successfully.*`);
}
return sections.join("\n\n");
}
function handleRawDetailsToggle(event: Event) {
const button = event.currentTarget as HTMLButtonElement | null;
const root = button?.closest(".chat-tool-card__raw");
const body = root?.querySelector<HTMLElement>(".chat-tool-card__raw-body");
if (!button || !body) {
return;
}
const expanded = button.getAttribute("aria-expanded") === "true";
button.setAttribute("aria-expanded", String(!expanded));
body.hidden = expanded;
}
function renderPreviewFrame(params: {
title: string;
src?: string;
height?: number;
sandbox?: string;
}) {
return html`
<iframe
class="chat-tool-card__preview-frame"
title=${params.title}
sandbox=${params.sandbox ?? ""}
src=${params.src ?? nothing}
style=${params.height ? `height:${params.height}px` : ""}
></iframe>
`;
}
export function renderToolPreview(
preview: ToolPreview | undefined,
surface: "chat_tool" | "chat_message" | "sidebar",
options?: {
onOpenSidebar?: (content: SidebarContent) => void;
rawText?: string | null;
canvasHostUrl?: string | null;
embedSandboxMode?: EmbedSandboxMode;
allowExternalEmbedUrls?: boolean;
},
) {
if (!preview) {
return nothing;
}
if (preview.kind !== "canvas" || surface === "chat_tool") {
return nothing;
}
if (preview.surface !== "assistant_message") {
return nothing;
}
return html`
<div class="chat-tool-card__preview" data-kind="canvas" data-surface=${surface}>
<div class="chat-tool-card__preview-header">
<span class="chat-tool-card__preview-label">${preview.title?.trim() || "Canvas"}</span>
</div>
<div class="chat-tool-card__preview-panel" data-side="canvas">
${renderPreviewFrame({
title: preview.title?.trim() || "Canvas",
src: resolveCanvasIframeUrl(
preview.url,
options?.canvasHostUrl,
options?.allowExternalEmbedUrls ?? false,
),
height: preview.preferredHeight,
sandbox:
preview.kind === "canvas"
? resolveEmbedSandbox(options?.embedSandboxMode ?? "scripts")
: resolveCanvasPreviewSandbox(preview),
})}
</div>
</div>
`;
}
export function buildSidebarContent(
value: string,
options?: { rawText?: string | null },
): SidebarContent {
return {
kind: "markdown",
content: value,
...(options?.rawText ? { rawText: options.rawText } : {}),
};
}
export function buildPreviewSidebarContent(
preview: ToolPreview,
rawText?: string | null,
): SidebarContent | null {
if (preview.kind !== "canvas" || preview.render !== "url" || !preview.viewId || !preview.url) {
return null;
}
return {
kind: "canvas",
docId: preview.viewId,
entryUrl: preview.url,
...(preview.title ? { title: preview.title } : {}),
...(preview.preferredHeight ? { preferredHeight: preview.preferredHeight } : {}),
...(rawText ? { rawText } : {}),
};
}
export function renderRawOutputToggle(text: string) {
return html`
<div class="chat-tool-card__raw">
<button
class="chat-tool-card__raw-toggle"
type="button"
aria-expanded="false"
@click=${handleRawDetailsToggle}
>
<span>Raw details</span>
<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,
})}
</div>
</div>
`;
}
function renderToolDataBlock(params: {
label: string;
text: string;
expanded: boolean;
empty?: boolean;
}) {
const { label, text, expanded, empty } = params;
return html`
<div class="chat-tool-card__block ${expanded ? "chat-tool-card__block--expanded" : ""}">
<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>${text}</code></pre>`
: html`<div class="chat-tool-card__block-preview mono">
${getTruncatedPreview(text)}
</div>`}
</div>
`;
}
function renderCollapsedToolSummary(params: {
label: string;
name: string;
expanded: boolean;
onToggleExpanded: () => void;
}) {
const { label, name, expanded, onToggleExpanded } = params;
return html`
<button
class="chat-tool-msg-summary"
type="button"
aria-expanded=${String(expanded)}
@click=${() => onToggleExpanded()}
>
<span class="chat-tool-msg-summary__icon">${icons.zap}</span>
<span class="chat-tool-msg-summary__label">${label}</span>
<span class="chat-tool-msg-summary__names">${name}</span>
</button>
`;
}
export function renderToolCard(
card: ToolCard,
opts: {
expanded: boolean;
onToggleExpanded: (id: string) => void;
onOpenSidebar?: (content: SidebarContent) => void;
canvasHostUrl?: string | null;
embedSandboxMode?: EmbedSandboxMode;
allowExternalEmbedUrls?: boolean;
},
) {
const hasOutput = Boolean(card.outputText?.trim());
const previewLabel = hasOutput ? "Tool output" : "Tool call";
return html`
<div
class="chat-tool-msg-collapse chat-tool-msg-collapse--manual ${opts.expanded
? "is-open"
: ""}"
>
${renderCollapsedToolSummary({
label: previewLabel,
name: card.name,
expanded: opts.expanded,
onToggleExpanded: () => opts.onToggleExpanded(card.id),
})}
${opts.expanded
? html`
<div class="chat-tool-msg-body">
${renderExpandedToolCardContent(
card,
opts.onOpenSidebar,
opts.canvasHostUrl,
opts.embedSandboxMode ?? "scripts",
opts.allowExternalEmbedUrls ?? false,
)}
</div>
`
: nothing}
</div>
`;
}
export function renderExpandedToolCardContent(
card: ToolCard,
onOpenSidebar?: (content: SidebarContent) => void,
canvasHostUrl?: string | null,
embedSandboxMode: EmbedSandboxMode = "scripts",
allowExternalEmbedUrls = false,
) {
const display = resolveToolDisplay({ name: card.name, args: card.args });
const detail = formatToolDetail(display);
const hasOutput = Boolean(card.outputText?.trim());
const hasInput = Boolean(card.inputText?.trim());
const canOpenSidebar = Boolean(onOpenSidebar);
const previewSidebarContent =
card.preview?.kind === "canvas"
? buildPreviewSidebarContent(card.preview, card.outputText)
: null;
const sidebarActionContent =
previewSidebarContent ?? buildSidebarContent(buildToolCardSidebarContent(card));
const visiblePreview = card.preview
? renderToolPreview(card.preview, "chat_tool", {
onOpenSidebar,
rawText: card.outputText,
canvasHostUrl,
embedSandboxMode,
allowExternalEmbedUrls,
})
: nothing;
return html`
<div class="chat-tool-card chat-tool-card--expanded">
<div class="chat-tool-card__header">
<div class="chat-tool-card__title">
<span class="chat-tool-card__icon">${icons[display.icon]}</span>
<span>${display.label}</span>
</div>
${canOpenSidebar
? html`
<div class="chat-tool-card__actions">
<button
class="chat-tool-card__action-btn"
type="button"
@click=${() => onOpenSidebar?.(sidebarActionContent)}
title="Open in the side panel"
aria-label="Open tool details in side panel"
>
<span class="chat-tool-card__action-icon">${icons.panelRightOpen}</span>
</button>
</div>
`
: nothing}
</div>
${detail ? html`<div class="chat-tool-card__detail">${detail}</div>` : nothing}
${hasInput
? renderToolDataBlock({
label: "Tool input",
text: card.inputText!,
expanded: true,
})
: nothing}
${hasOutput
? card.preview
? html`${visiblePreview} ${renderRawOutputToggle(card.outputText!)}`
: renderToolDataBlock({
label: "Tool output",
text: card.outputText!,
expanded: true,
})
: nothing}
</div>
`;
}
export function renderToolCardSidebar(
card: ToolCard,
onOpenSidebar?: (content: SidebarContent) => void,
canvasHostUrl?: string | null,
embedSandboxMode: EmbedSandboxMode = "scripts",
) {
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 sidebarContent =
preview?.kind === "canvas"
? buildPreviewSidebarContent(preview, card.outputText)
: buildSidebarContent(buildToolCardSidebarContent(card));
const actionContent = sidebarContent ?? buildSidebarContent(buildToolCardSidebarContent(card));
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;
return html`
<div
class="chat-tool-card ${canClick ? "chat-tool-card--clickable" : ""}"
@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">${icons[display.icon]}</span>
<span>${display.label}</span>
</div>
${canClick
? html`<span class="chat-tool-card__action"
>${hasText || hasPreview ? "View" : ""} ${icons.check}</span
>`
: nothing}
${isEmpty && !canClick
? html`<span class="chat-tool-card__status">${icons.check}</span>`
: nothing}
</div>
${detail ? html`<div class="chat-tool-card__detail">${detail}</div>` : nothing}
${isEmpty ? html`<div class="chat-tool-card__status-text muted">Completed</div>` : nothing}
${preview
? html`${renderToolPreview(preview, "chat_tool", {
onOpenSidebar,
rawText: card.outputText,
canvasHostUrl,
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>
`;
}