From a5fdc07121862a49fd2d17e590a17e68492dfb3e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Jul 2026 06:56:58 -0700 Subject: [PATCH] fix(ui): simplify grouped tool activity (#100318) --- ui/src/lib/chat/chat-types.ts | 1 + ui/src/lib/chat/message-normalizer.ts | 20 ++- ui/src/lib/chat/tool-cards.ts | 79 ++++++-- .../chat/chat-responsive.browser.test.ts | 42 +++-- ui/src/pages/chat/chat-thread.test.ts | 170 ++++++++++++++++++ ui/src/pages/chat/chat-thread.ts | 115 ++++++++++-- .../chat/components/chat-message.test.ts | 163 ++++++++++++++++- ui/src/pages/chat/components/chat-message.ts | 84 ++++----- .../components/chat-tool-cards.node.test.ts | 20 +++ .../chat/components/chat-tool-cards.test.ts | 11 ++ .../pages/chat/components/chat-tool-cards.ts | 6 +- ui/src/styles/chat/tool-cards.css | 14 +- 12 files changed, 617 insertions(+), 108 deletions(-) diff --git a/ui/src/lib/chat/chat-types.ts b/ui/src/lib/chat/chat-types.ts index 495583d26496..f4b35d477063 100644 --- a/ui/src/lib/chat/chat-types.ts +++ b/ui/src/lib/chat/chat-types.ts @@ -131,6 +131,7 @@ export type NormalizedMessage = { /** Tool card representation for inline tool call/result rendering */ export type ToolCard = { id: string; + callId?: string; name: string; args?: unknown; inputText?: string; diff --git a/ui/src/lib/chat/message-normalizer.ts b/ui/src/lib/chat/message-normalizer.ts index 1c62560d5c56..bd2592e61b62 100644 --- a/ui/src/lib/chat/message-normalizer.ts +++ b/ui/src/lib/chat/message-normalizer.ts @@ -42,6 +42,20 @@ export function isToolResultMessage(message: unknown): boolean { return role === "toolresult" || role === "tool_result"; } +export function isStandaloneToolMessageForDisplay(message: unknown): boolean { + const m = message as Record; + const role = typeof m.role === "string" ? normalizeRoleForGrouping(m.role) : "unknown"; + return ( + role === "tool" || + typeof m.toolCallId === "string" || + typeof m.tool_call_id === "string" || + typeof m.toolUseId === "string" || + typeof m.tool_use_id === "string" || + typeof m.toolName === "string" || + typeof m.tool_name === "string" + ); +} + function isTextContentBlock( item: Record, role: string, @@ -349,7 +363,11 @@ export function normalizeMessage(message: unknown): NormalizedMessage { // Detect tool messages by common gateway shapes. // Some tool events come through as assistant role with tool_* items in the content array. - const hasToolId = typeof m.toolCallId === "string" || typeof m.tool_call_id === "string"; + const hasToolId = + typeof m.toolCallId === "string" || + typeof m.tool_call_id === "string" || + typeof m.toolUseId === "string" || + typeof m.tool_use_id === "string"; const contentRaw = m.content; const contentItems = Array.isArray(contentRaw) ? contentRaw : null; diff --git a/ui/src/lib/chat/tool-cards.ts b/ui/src/lib/chat/tool-cards.ts index 6d20dcf9c67c..3fdaf1145845 100644 --- a/ui/src/lib/chat/tool-cards.ts +++ b/ui/src/lib/chat/tool-cards.ts @@ -1,5 +1,10 @@ // Control UI chat domain owns pure tool-card extraction rules. import { extractCanvasFromText } from "../../../../src/chat/canvas-render.js"; +import { + isToolCallContentType, + isToolResultContentType, + resolveToolUseId, +} from "../../../../src/chat/tool-content.js"; import type { ToolCard } from "./chat-types.ts"; import { extractTextCached } from "./message-extract.ts"; import { isToolResultMessage } from "./message-normalizer.ts"; @@ -142,28 +147,41 @@ export function extractToolPreview( return extractCanvasFromText(outputText, toolName); } +function resolveToolCallId( + item: Record, + message: Record, +): string | undefined { + return ( + resolveToolUseId(item) || + (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()) || + (typeof message.toolUseId === "string" && message.toolUseId.trim()) || + (typeof message.tool_use_id === "string" && message.tool_use_id.trim()) || + undefined + ); +} + +function resolveToolName(item: Record, message: Record): string { + return ( + (typeof item.name === "string" && item.name.trim()) || + (typeof message.toolName === "string" && message.toolName.trim()) || + (typeof message.tool_name === "string" && message.tool_name.trim()) || + "tool" + ); +} + function resolveToolCardId( item: Record, message: Record, 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()) || - ""; + const explicitId = resolveToolCallId(item, message); 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"; + const name = resolveToolName(item, message); return `${prefix}:${name}:${index}`; } @@ -196,6 +214,25 @@ export function formatCollapsedToolSummaryText(value: string | undefined): strin return withoutConnector || normalized; } +function collapsedToolTextKey(value: string | undefined): string | undefined { + return formatCollapsedToolSummaryText(value) + ?.toLowerCase() + .replace(/[\s._-]+/g, ""); +} + +export function formatDistinctCollapsedToolSummaryText( + value: string | undefined, + label: string | undefined, +): string | undefined { + const displayValue = formatCollapsedToolSummaryText(value); + if (!displayValue) { + return undefined; + } + const valueKey = collapsedToolTextKey(displayValue); + const labelKey = collapsedToolTextKey(label); + return valueKey && labelKey && valueKey === labelKey ? undefined : displayValue; +} + export function formatCollapsedToolPreviewText(value: string | undefined): string | undefined { const normalized = formatCollapsedToolSummaryText(value); if (!normalized) { @@ -237,16 +274,17 @@ export function extractToolCards(message: unknown, prefix = "tool"): 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) || + isToolCallContentType(item.type) || (typeof item.name === "string" && (item.arguments != null || item.args != null || item.input != null)); if (isToolCall) { const args = coerceArgs(item.arguments ?? item.args ?? item.input); + const callId = resolveToolCallId(item, m); cards.push({ id: resolveToolCardId(item, m, index, prefix), - name: typeof item.name === "string" ? item.name : "tool", + ...(callId ? { callId } : {}), + name: resolveToolName(item, m), args, inputText: serializeToolInput(args), messageId: transcriptMessageId, @@ -254,15 +292,17 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[] continue; } - if (kind === "toolresult" || kind === "tool_result") { - const name = typeof item.name === "string" ? item.name : "tool"; + if (isToolResultContentType(item.type)) { + const name = resolveToolName(item, m); const cardId = resolveToolCardId(item, m, index, prefix); + const callId = resolveToolCallId(item, m); const existing = findFirstUnmatchedCard(cards, cardId, name, fallbackMatchedCards); const text = extractToolText(item); const preview = extractToolPreview(text, name); const isError = readToolErrorFlag(item) ?? messageIsError; if (existing) { fallbackMatchedCards.add(existing); + existing.callId ??= callId; existing.outputText = text; existing.preview = preview; if (isError !== undefined) { @@ -272,6 +312,7 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[] } cards.push({ id: cardId, + ...(callId ? { callId } : {}), name, outputText: text, messageId: transcriptMessageId, @@ -295,8 +336,10 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[] (typeof m.tool_name === "string" && m.tool_name) || "tool"; const text = extractTextCached(message) ?? undefined; + const callId = resolveToolCallId({}, m); cards.push({ id: resolveToolCardId({}, m, 0, prefix), + ...(callId ? { callId } : {}), name, outputText: text, messageId: transcriptMessageId, diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts index 50ac7e4eec1d..8a2daf3e6b9b 100644 --- a/ui/src/pages/chat/chat-responsive.browser.test.ts +++ b/ui/src/pages/chat/chat-responsive.browser.test.ts @@ -93,11 +93,18 @@ function activityAlignmentHtml() {
-
-
Bash searched a deliberately long workspace path with enough detail to occupy the activity row and expose mismatched width constraints.
+
+
+
+ +
+
@@ -503,17 +510,26 @@ describeBrowserLayout("chat responsive browser layout", () => { ); await expectNoHorizontalOverflow(page); - const callBubble = await getRect(page, "[data-activity-call-bubble]"); + const callRow = await getRect(page, "[data-activity-call-row]"); const errorSummary = await getRect(page, ".chat-tool-msg-summary--error"); - expect(Math.abs(callBubble.right - errorSummary.right)).toBeLessThanOrEqual(1); - const selectionStyles = await page.evaluate(() => ({ - activity: getComputedStyle( - document.querySelector(".chat-activity-group__summary")!, - ).userSelect, - tool: getComputedStyle(document.querySelector(".chat-tool-msg-summary")!) - .userSelect, - })); - expect(selectionStyles).toEqual({ activity: "text", tool: "text" }); + expect(Math.abs(callRow.right - errorSummary.right)).toBeLessThanOrEqual(1); + expect(Math.abs(callRow.height - errorSummary.height)).toBeLessThanOrEqual(1); + const styles = await page.evaluate(() => { + const call = document.querySelector("[data-activity-call-row]")!; + return { + activity: getComputedStyle( + document.querySelector(".chat-activity-group__summary")!, + ).userSelect, + callBackground: getComputedStyle(call).backgroundColor, + tool: getComputedStyle(document.querySelector(".chat-tool-msg-summary")!) + .userSelect, + }; + }); + expect(styles).toEqual({ + activity: "text", + callBackground: "rgba(0, 0, 0, 0)", + tool: "text", + }); } finally { await closeBrowserPage(page); } diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index 26c13f6dd8d8..b6b0a0546e05 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -1,6 +1,7 @@ // Control UI tests cover build chat items behavior. import { describe, expect, it } from "vitest"; import type { MessageGroup } from "../../lib/chat/chat-types.ts"; +import { extractToolCards } from "../../lib/chat/tool-cards.ts"; import { buildCachedChatItems, buildChatItems, @@ -96,6 +97,23 @@ describe("buildChatItems", () => { expect(groups[0].messages).toHaveLength(2); }); + it("groups and hides top-level tool-use id results consistently", () => { + const message = { + role: "assistant", + toolUseId: "provider-result", + toolName: "bash", + content: "Provider output", + timestamp: 1000, + }; + + const visibleGroups = messageGroups({ messages: [message] }); + expect(visibleGroups).toHaveLength(1); + expect(visibleGroups[0].role).toBe("tool"); + + const hiddenGroups = messageGroups({ messages: [message], showToolCalls: false }); + expect(hiddenGroups).toHaveLength(0); + }); + it("keeps forwarded assistant display messages separate from local assistant replies", () => { const groups = messageGroups({ messages: [ @@ -146,6 +164,133 @@ describe("buildChatItems", () => { expect(toolGroups.map((group) => group.turnSucceeded)).toEqual([true, false]); }); + it("coalesces adjacent tool calls and results into one activity item", () => { + const groups = messageGroups({ + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "call-shell", + name: "bash", + input: { command: "run openclaw doctor" }, + }, + ], + timestamp: 1000, + }, + { + role: "toolResult", + toolCallId: "call-shell", + toolName: "bash", + content: [ + { type: "text", text: "Doctor complete" }, + { type: "image", data: "fixture-image", mimeType: "image/png" }, + ], + isError: false, + timestamp: 1001, + }, + ], + }); + + expect(groups).toHaveLength(1); + expect(groups[0].role).toBe("tool"); + expect(groups[0].messages).toHaveLength(1); + const cards = extractToolCards(groups[0].messages[0]?.message, "coalesced"); + expect(cards).toHaveLength(1); + expect(cards[0]).toMatchObject({ + callId: "call-shell", + name: "bash", + outputText: "Doctor complete", + }); + expect(firstMessageContent(groups[0])).toContainEqual({ + type: "image", + data: "fixture-image", + mimeType: "image/png", + }); + }); + + it("coalesces provider-shaped result blocks by canonical tool-use id", () => { + const groups = messageGroups({ + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + toolUseId: "provider-call", + name: "bash", + input: { command: "provider command" }, + }, + ], + timestamp: 1000, + }, + { + role: "assistant", + content: [ + { + type: "tool_result", + tool_use_id: "provider-call", + text: "Provider result", + }, + ], + timestamp: 1001, + }, + ], + }); + + expect(groups).toHaveLength(1); + expect(groups[0].messages).toHaveLength(1); + const cards = extractToolCards(groups[0].messages[0]?.message, "provider-coalesced"); + expect(cards).toHaveLength(1); + expect(cards[0]).toMatchObject({ + callId: "provider-call", + name: "bash", + outputText: "Provider result", + }); + }); + + it("does not coalesce repeated call-only snapshots", () => { + const callSnapshot = (timestamp: number) => ({ + role: "assistant", + content: [ + { + type: "tool_use", + id: "call-pending", + name: "bash", + input: { command: "still running" }, + }, + ], + timestamp, + }); + const groups = messageGroups({ messages: [callSnapshot(1000), callSnapshot(1001)] }); + + expect(groups).toHaveLength(1); + expect(groups[0].messages).toHaveLength(2); + }); + + it("keeps adjacent tool messages separate when their call ids differ", () => { + const groups = messageGroups({ + messages: [ + { + role: "assistant", + content: [{ type: "tool_use", id: "call-a", name: "bash", input: { command: "one" } }], + timestamp: 1000, + }, + { + role: "toolResult", + toolCallId: "call-b", + toolName: "bash", + content: "Different call", + timestamp: 1001, + }, + ], + }); + + expect(groups).toHaveLength(1); + expect(groups[0].messages).toHaveLength(2); + }); + it("keeps empty forwarded assistant display groups", () => { const groups = messageGroups({ messages: [ @@ -1238,6 +1383,31 @@ describe("tool expansion state", () => { syncToolCardExpansionState("main", [group], true); expect(getExpandedToolCards("main").get("assistant-1:toolcard:0")).toBe(true); }); + + it("auto-expands top-level tool-name result disclosures", () => { + resetChatThreadState(); + const group: MessageGroup = { + kind: "group", + key: "tool-name-result", + role: "tool", + messages: [ + { + key: "tool-name-result", + message: { + role: "assistant", + toolName: "bash", + content: "Tool output", + }, + }, + ], + timestamp: 1, + isStreaming: false, + }; + + syncToolCardExpansionState("tool-name-session", [group], true); + + expect(getExpandedToolCards("tool-name-session").get("toolmsg:tool-name-result")).toBe(true); + }); }); describe("thread item cache", () => { diff --git a/ui/src/pages/chat/chat-thread.ts b/ui/src/pages/chat/chat-thread.ts index e64f354d735f..9b6dbeca69c0 100644 --- a/ui/src/pages/chat/chat-thread.ts +++ b/ui/src/pages/chat/chat-thread.ts @@ -1,4 +1,8 @@ // Control UI chat module owns Chat thread item derivation and thread-local caches. +import { + isToolCallContentType, + isToolResultContentType, +} from "../../../../src/chat/tool-content.js"; import type { ChatItem, MessageGroup, @@ -22,7 +26,7 @@ import { } from "../../lib/chat/heartbeat-display.ts"; import { extractTextCached } from "../../lib/chat/message-extract.ts"; import { - isToolResultMessage, + isStandaloneToolMessageForDisplay, normalizeMessage, stripMessageDisplayMetadataText, } from "../../lib/chat/message-normalizer.ts"; @@ -283,6 +287,97 @@ function groupMessages(items: ChatItem[]): Array { return result; } +function mergeToolCallResultPair(callItem: ChatItem, resultItem: ChatItem): ChatItem | null { + if (callItem.kind !== "message" || resultItem.kind !== "message") { + return null; + } + const callMessage = asRecord(callItem.message); + const resultMessage = asRecord(resultItem.message); + if (!callMessage || !resultMessage) { + return null; + } + const callRole = typeof callMessage.role === "string" ? callMessage.role.toLowerCase() : ""; + const normalizedResult = safeNormalizeMessage(resultItem.message); + const resultRole = normalizedResult ? normalizeRoleForGrouping(normalizedResult.role) : "unknown"; + if (callRole !== "assistant" || resultRole !== "tool" || !Array.isArray(callMessage.content)) { + return null; + } + const hasToolCallBlock = callMessage.content.some((block) => + isToolCallContentType(asRecord(block)?.type), + ); + if (!hasToolCallBlock) { + return null; + } + + const callCards = extractToolCardsCached(callItem.message, `${callItem.key}:activity-call`); + const resultCards = extractToolCardsCached( + resultItem.message, + `${resultItem.key}:activity-result`, + ); + if (callCards.length !== 1 || resultCards.length !== 1) { + return null; + } + const [callCard] = callCards; + const [resultCard] = resultCards; + const resultName = resultCard.name === "tool" ? callCard.name : resultCard.name; + const rawResultContent = Array.isArray(resultMessage.content) ? resultMessage.content : []; + const resultOnlyContent = rawResultContent.filter( + (block) => !isToolCallContentType(asRecord(block)?.type), + ); + const hasToolResultBlock = resultOnlyContent.some((block) => + isToolResultContentType(asRecord(block)?.type), + ); + const hasToolResult = + hasToolResultBlock || resultCard.outputText !== undefined || resultCard.isError !== undefined; + if ( + !callCard.callId || + callCard.callId !== resultCard.callId || + !hasToolResult || + normalizeLowercaseStringOrEmpty(callCard.name) !== normalizeLowercaseStringOrEmpty(resultName) + ) { + return null; + } + + const preservedResultContent = resultOnlyContent.filter( + (block) => asRecord(block)?.type !== "text", + ); + const resultContent = hasToolResultBlock + ? resultOnlyContent + : [ + { + type: "tool_result", + id: resultCard.callId, + name: resultName, + text: resultCard.outputText ?? "", + ...(resultCard.isError !== undefined ? { isError: resultCard.isError } : {}), + }, + ...preservedResultContent, + ]; + const resultError = resultMessage.isError ?? resultMessage.is_error; + return { + ...callItem, + message: { + ...callMessage, + content: [...callMessage.content, ...resultContent], + ...(typeof resultError === "boolean" ? { isError: resultError } : {}), + }, + }; +} + +function coalesceToolActivityMessages(items: ChatItem[]): ChatItem[] { + const coalesced: ChatItem[] = []; + for (const item of items) { + const previous = coalesced[coalesced.length - 1]; + const merged = previous ? mergeToolCallResultPair(previous, item) : null; + if (merged) { + coalesced[coalesced.length - 1] = merged; + } else { + coalesced.push(item); + } + } + return coalesced; +} + function assistantGroupHasReplyText(group: MessageGroup): boolean { return group.messages.some(({ message }) => Boolean(extractTextCached(message)?.trim())); } @@ -940,7 +1035,11 @@ export function buildChatItems(props: BuildChatItemsProps): Array; - const role = typeof messageRecord.role === "string" ? messageRecord.role : "unknown"; - const normalizedRole = normalizeRoleForGrouping(role); - const isToolMessage = - isToolResultMessage(entry.message) || - normalizedRole === "tool" || - role.toLowerCase() === "toolresult" || - role.toLowerCase() === "tool_result" || - typeof messageRecord.toolCallId === "string" || - typeof messageRecord.tool_call_id === "string"; - if (!isToolMessage) { + if (!isStandaloneToolMessageForDisplay(entry.message)) { continue; } const disclosureId = `toolmsg:${entry.key}`; diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index 1a3b64953034..40b5e2a59656 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -1104,8 +1104,9 @@ describe("grouped chat rendering", () => { const activity = expectElement(container, ".chat-activity-group__summary", HTMLButtonElement); expect(activity.textContent).toContain("Activity: 2 tools"); - expect(activity.textContent).toContain("read_file"); - expect(activity.textContent).toContain("run_command"); + expect(activity.querySelector(".chat-activity-group__preview")).toBeNull(); + expect(activity.textContent).not.toContain("read_file"); + expect(activity.textContent).not.toContain("run_command"); expect(container.querySelector(".chat-tool-msg-body")).toBeNull(); }); @@ -1216,6 +1217,62 @@ describe("grouped chat rendering", () => { expect(container.querySelector(".chat-tool-msg-body")).toBeNull(); }); + it("keeps recovered coalesced tool failures neutral in the activity list", () => { + const container = document.createElement("div"); + const group: MessageGroup = { + kind: "group", + key: "recovered-tool-group", + role: "tool", + turnSucceeded: true, + messages: [ + { + key: "recovered-tool-message", + message: { + role: "assistant", + isError: true, + content: [ + { + type: "tool_use", + id: "call-recovered", + name: "bash", + input: { command: "run fallback" }, + }, + { + type: "tool_result", + id: "call-recovered", + name: "bash", + text: "Primary path failed", + isError: true, + }, + ], + timestamp: 1000, + }, + }, + { + key: "recovered-followup", + message: { + role: "toolResult", + toolCallId: "call-followup", + toolName: "read_file", + content: "Fallback context", + timestamp: 1001, + }, + }, + ], + timestamp: 1000, + isStreaming: false, + }; + + renderMessageGroups(container, [group], { + isToolMessageExpanded: (id) => id === "activity:recovered-tool-group", + }); + + const summaries = container.querySelectorAll(".chat-tool-msg-summary"); + expect(summaries).toHaveLength(2); + expect(container.querySelector(".chat-tool-msg-summary--error")).toBeNull(); + expect(summaries[0]?.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("bash"); + }); + it("hides grouped tool activity when tool calls are disabled", () => { const container = document.createElement("div"); const group: MessageGroup = { @@ -1330,6 +1387,78 @@ describe("grouped chat rendering", () => { ); }); + it("renders assistant tool content as a flat concise tool row without a top-level call id", () => { + const container = document.createElement("div"); + const message = { + id: "assistant-tool-content", + role: "assistant", + content: [ + { + type: "tool_use", + id: "call-content-only", + name: "bash", + input: { command: "bash" }, + }, + ], + timestamp: Date.now(), + }; + + renderAssistantMessage(container, message, { + isToolMessageExpanded: () => false, + }); + + expectElement(container, ".chat-bubble--tool-shell", HTMLElement); + const summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement); + expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("bash"); + expect(summary.querySelector(".chat-tool-msg-summary__names")).toBeNull(); + }); + + it("keeps top-level tool-name results collapsed", () => { + const container = document.createElement("div"); + renderAssistantMessage( + container, + { + role: "assistant", + toolName: "bash", + content: "A long tool result that should stay behind the disclosure.", + timestamp: Date.now(), + }, + { isToolMessageExpanded: () => false }, + ); + + expectElement(container, ".chat-bubble--tool-shell", HTMLElement); + expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement); + expect(container.querySelector(".chat-tool-msg-body")).toBeNull(); + expect(container.querySelector(".chat-text")).toBeNull(); + }); + + it("omits normalized duplicate names from standalone tool results", () => { + const container = document.createElement("div"); + const message = { + role: "toolResult", + toolCallId: "call-heartbeat", + toolName: "heartbeat_respond", + content: [ + { + type: "tool_result", + name: "heartbeat_respond", + text: "Acknowledged", + }, + ], + timestamp: Date.now(), + }; + + renderAssistantMessage(container, message, { + isToolMessageExpanded: () => false, + }); + + const summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement); + expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe( + "heartbeat_respond", + ); + expect(summary.querySelector(".chat-tool-msg-summary__names")).toBeNull(); + }); + it("cleans collapsed tool connector copy while preserving expanded raw input", () => { const container = document.createElement("div"); const message = { @@ -2531,6 +2660,36 @@ describe("grouped chat rendering", () => { ); }); + it("keeps lifted assistant canvas previews beside flat tool rows", () => { + const container = document.createElement("div"); + renderAssistantMessage( + container, + { + id: "assistant-tool-canvas", + role: "assistant", + toolName: "bash", + content: [ + { + type: "tool_use", + id: "call-tool-canvas", + name: "bash", + input: { command: "render preview" }, + }, + createAssistantCanvasBlock({ suffix: "tool_canvas" }), + ], + timestamp: Date.now(), + }, + { showToolCalls: true, isToolMessageExpanded: () => true }, + ); + + expectElement(container, ".chat-bubble--tool-shell", HTMLElement); + const iframe = expectElement(container, ".chat-tool-card__preview-frame", HTMLIFrameElement); + expect(iframe.getAttribute("src")).toBe( + "/__openclaw__/canvas/documents/cv_inline_tool_canvas/index.html", + ); + expect(container.querySelector(".chat-tool-msg-summary")).not.toBeNull(); + }); + it("reserves layout space for assistant message actions", () => { const container = document.createElement("div"); renderAssistantMessage(container, { diff --git a/ui/src/pages/chat/components/chat-message.ts b/ui/src/pages/chat/components/chat-message.ts index eace4be2a45e..2fa53a38ea04 100644 --- a/ui/src/pages/chat/components/chat-message.ts +++ b/ui/src/pages/chat/components/chat-message.ts @@ -28,10 +28,14 @@ import { extractThinkingCached, formatReasoningMarkdown, } from "../../../lib/chat/message-extract.ts"; -import { isToolResultMessage, normalizeMessage } from "../../../lib/chat/message-normalizer.ts"; +import { + isStandaloneToolMessageForDisplay, + normalizeMessage, +} from "../../../lib/chat/message-normalizer.ts"; import { normalizeRoleForGrouping } from "../../../lib/chat/message-normalizer.ts"; import { extractToolCardsCached, + formatDistinctCollapsedToolSummaryText, formatCollapsedToolPreviewText, formatCollapsedToolSummaryText, isToolCardError, @@ -648,24 +652,6 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup if (normalizedRole === "tool" && group.messages.length > 1) { const cards = group.messages.flatMap((item) => extractToolCardsCached(item.message, item.key)); const toolCount = cards.length || group.messages.length; - const toolLabels = [ - ...new Set( - cards.map( - (card) => - resolveToolDisplay({ - name: card.name, - args: card.args, - detailMode: "explain", - }).label, - ), - ), - ]; - const preview = - toolLabels.length === 0 - ? "Tool output" - : toolLabels.length <= 3 - ? toolLabels.join(", ") - : `${toolLabels.slice(0, 2).join(", ")} +${toolLabels.length - 2} more`; const hasError = cards.some(isToolCardError) && group.turnSucceeded !== true; const activityDisclosureId = `activity:${group.key}`; const activityExpanded = opts.isToolMessageExpanded?.(activityDisclosureId) ?? hasError; @@ -694,7 +680,7 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup type="button" aria-expanded=${String(activityExpanded)} aria-label=${hasError - ? `Activity: ${toolCount} tool${toolCount === 1 ? "" : "s"}, includes errors. ${preview}` + ? `Activity: ${toolCount} tool${toolCount === 1 ? "" : "s"}, includes errors.` : nothing} @click=${(event: MouseEvent) => { if (shouldToggleSelectableDisclosure(event)) { @@ -706,7 +692,6 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup Activity: ${toolCount} tool${toolCount === 1 ? "" : "s"} - ${preview}
` : nothing} - ${isToolMessage + ${isStandaloneToolMessage ? html`
${unsafeHTML(toSanitizedMarkdownHtml(reasoningMarkdown))} @@ -2053,6 +2051,7 @@ function renderGroupedMessage( onOpenSidebar, isToolExpanded: opts.isToolExpanded, onToggleToolExpanded: opts.onToggleToolExpanded, + turnSucceeded: opts.turnSucceeded, canvasPluginSurfaceUrl: opts.canvasPluginSurfaceUrl, embedSandboxMode: opts.embedSandboxMode ?? "scripts", allowExternalEmbedUrls: opts.allowExternalEmbedUrls ?? false, @@ -2079,17 +2078,7 @@ function renderGroupedMessage( ${unsafeHTML(toSanitizedMarkdownHtml(reasoningMarkdown))}
` : nothing} - ${normalizedRole === "assistant" && assistantViewBlocks.length > 0 - ? html`${assistantViewBlocks.map( - (block) => html`${renderToolPreview(block.preview, "chat_message", { - onOpenSidebar, - rawText: block.rawText ?? null, - canvasPluginSurfaceUrl: opts.canvasPluginSurfaceUrl, - embedSandboxMode: opts.embedSandboxMode ?? "scripts", - })} - ${block.rawText ? renderRawOutputToggle(block.rawText) : nothing}`, - )}` - : nothing} + ${assistantViewContent} ${jsonResult ? html`
@@ -2109,6 +2098,7 @@ function renderGroupedMessage( onOpenSidebar, isToolExpanded: opts.isToolExpanded, onToggleToolExpanded: opts.onToggleToolExpanded, + turnSucceeded: opts.turnSucceeded, canvasPluginSurfaceUrl: opts.canvasPluginSurfaceUrl, embedSandboxMode: opts.embedSandboxMode ?? "scripts", allowExternalEmbedUrls: opts.allowExternalEmbedUrls ?? false, diff --git a/ui/src/pages/chat/components/chat-tool-cards.node.test.ts b/ui/src/pages/chat/components/chat-tool-cards.node.test.ts index 68e022301f62..dfce24bf94f2 100644 --- a/ui/src/pages/chat/components/chat-tool-cards.node.test.ts +++ b/ui/src/pages/chat/components/chat-tool-cards.node.test.ts @@ -104,6 +104,26 @@ describe("tool-card extraction", () => { }`); }); + it("preserves legacy callId tool block identities", () => { + const cards = extractToolCards( + { + role: "assistant", + content: [ + { + type: "tool_use", + callId: "legacy-call-id", + name: "bash", + input: { command: "pwd" }, + }, + ], + }, + "legacy-call", + ); + + expect(cards[0]?.callId).toBe("legacy-call-id"); + expect(cards[0]?.id).toBe("legacy-call:legacy-call-id"); + }); + it("pairs interleaved nameless tool results in content order", () => { const cards = extractToolCards( { diff --git a/ui/src/pages/chat/components/chat-tool-cards.test.ts b/ui/src/pages/chat/components/chat-tool-cards.test.ts index fa7885621e51..f067b57b5efd 100644 --- a/ui/src/pages/chat/components/chat-tool-cards.test.ts +++ b/ui/src/pages/chat/components/chat-tool-cards.test.ts @@ -29,6 +29,7 @@ vi.mock("../tool-display.ts", () => ({ })); import { + formatDistinctCollapsedToolSummaryText, formatCollapsedToolPreviewText, formatCollapsedToolSummaryText, isToolErrorOutput, @@ -237,6 +238,16 @@ describe("tool-cards", () => { expect(formatCollapsedToolSummaryText(" ")).toBeUndefined(); }); + it("omits normalized tool details that repeat the label", () => { + expect(formatDistinctCollapsedToolSummaryText("bash", "Bash")).toBeUndefined(); + expect( + formatDistinctCollapsedToolSummaryText("heartbeat_respond", "Heartbeat Respond"), + ).toBeUndefined(); + expect(formatDistinctCollapsedToolSummaryText("run openclaw doctor", "Bash")).toBe( + "run openclaw doctor", + ); + }); + it("keeps collapsed markdown previews bounded after display cleanup", () => { const preview = formatCollapsedToolPreviewText(`with ${"A".repeat(200)}`); diff --git a/ui/src/pages/chat/components/chat-tool-cards.ts b/ui/src/pages/chat/components/chat-tool-cards.ts index e407fd39885e..130ce8ed439c 100644 --- a/ui/src/pages/chat/components/chat-tool-cards.ts +++ b/ui/src/pages/chat/components/chat-tool-cards.ts @@ -7,6 +7,7 @@ import "../../../components/tooltip.ts"; import { t } from "../../../i18n/index.ts"; import type { ToolCard } from "../../../lib/chat/chat-types.ts"; import { + formatDistinctCollapsedToolSummaryText, formatCollapsedToolPreviewText, formatCollapsedToolSummaryText, isToolCardError, @@ -274,7 +275,7 @@ function renderCollapsedToolSummary(params: { }) { const { label, icon, name, expanded, isError, onToggleExpanded } = params; const displayLabel = formatCollapsedToolSummaryText(label) ?? label; - const displayName = formatCollapsedToolSummaryText(name); + const displayName = formatDistinctCollapsedToolSummaryText(name, displayLabel); return html`