fix(ui): simplify grouped tool activity (#100318)

This commit is contained in:
Peter Steinberger
2026-07-05 06:56:58 -07:00
committed by GitHub
parent c3bcbf59b1
commit a5fdc07121
12 changed files with 617 additions and 108 deletions
+1
View File
@@ -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;
+19 -1
View File
@@ -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<string, unknown>;
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<string, unknown>,
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;
+61 -18
View File
@@ -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<string, unknown>,
message: Record<string, unknown>,
): 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<string, unknown>, message: Record<string, unknown>): 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<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()) ||
"";
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,
@@ -93,11 +93,18 @@ function activityAlignmentHtml() {
<button class="chat-activity-group__summary chat-activity-group__summary--error" type="button">
<span class="chat-activity-group__icon">${iconSvg()}</span>
<span class="chat-activity-group__label">Activity: 2 tools</span>
<span class="chat-activity-group__preview">Bash, Gateway</span>
</button>
<div class="chat-activity-group__body">
<div class="chat-bubble" data-activity-call-bubble>
<div class="chat-text">Bash searched a deliberately long workspace path with enough detail to occupy the activity row and expose mismatched width constraints.</div>
<div class="chat-bubble chat-bubble--tool-shell" data-activity-call-row>
<div class="chat-tools-inline">
<div class="chat-tool-msg-collapse">
<button class="chat-tool-msg-summary" type="button">
<span class="chat-tool-msg-summary__icon">${iconSvg()}</span>
<span class="chat-tool-msg-summary__label">Bash</span>
<span class="chat-tool-msg-summary__names">search a deliberately long workspace path without extra card chrome</span>
</button>
</div>
</div>
</div>
<div class="chat-bubble chat-bubble--tool-shell">
<div class="chat-tool-msg-collapse">
@@ -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<HTMLElement>(".chat-activity-group__summary")!,
).userSelect,
tool: getComputedStyle(document.querySelector<HTMLElement>(".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<HTMLElement>("[data-activity-call-row]")!;
return {
activity: getComputedStyle(
document.querySelector<HTMLElement>(".chat-activity-group__summary")!,
).userSelect,
callBackground: getComputedStyle(call).backgroundColor,
tool: getComputedStyle(document.querySelector<HTMLElement>(".chat-tool-msg-summary")!)
.userSelect,
};
});
expect(styles).toEqual({
activity: "text",
callBackground: "rgba(0, 0, 0, 0)",
tool: "text",
});
} finally {
await closeBrowserPage(page);
}
+170
View File
@@ -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", () => {
+102 -13
View File
@@ -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<ChatItem | MessageGroup> {
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<ChatItem | Mes
}
return annotateToolTurnOutcome(
groupMessages(collapseSequentialDuplicateMessages(sortChatItemsByVisibleTime(items))),
groupMessages(
collapseSequentialDuplicateMessages(
coalesceToolActivityMessages(sortChatItemsByVisibleTime(items)),
),
),
);
}
@@ -1055,17 +1154,7 @@ export function syncToolCardExpansionState(
expanded.set(disclosureId, autoExpandToolCalls);
initialized.add(disclosureId);
}
const messageRecord = entry.message as Record<string, unknown>;
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}`;
@@ -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, {
+37 -47
View File
@@ -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
<span class="chat-activity-group__label"
>Activity: ${toolCount} tool${toolCount === 1 ? "" : "s"}</span
>
<span class="chat-activity-group__preview">${preview}</span>
<span
class="collapse-chevron ${activityExpanded ? "" : "collapse-chevron--collapsed"}"
aria-hidden="true"
@@ -1678,6 +1663,7 @@ function renderInlineToolCards(
onOpenSidebar?: (content: SidebarContent) => void;
isToolExpanded?: (toolCardId: string) => boolean;
onToggleToolExpanded?: (toolCardId: string) => void;
turnSucceeded?: boolean;
canvasPluginSurfaceUrl?: string | null;
embedSandboxMode?: EmbedSandboxMode;
allowExternalEmbedUrls?: boolean;
@@ -1688,6 +1674,7 @@ function renderInlineToolCards(
${toolCards.map((card, index) =>
renderToolCard(card, {
expanded: opts.isToolExpanded?.(`${opts.messageKey}:toolcard:${index}`) ?? false,
turnSucceeded: opts.turnSucceeded,
onToggleExpanded: opts.onToggleToolExpanded
? () => opts.onToggleToolExpanded?.(`${opts.messageKey}:toolcard:${index}`)
: () => undefined,
@@ -1817,13 +1804,11 @@ function renderGroupedMessage(
) {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown";
const normalizedRole = normalizeRoleForGrouping(role);
const isToolResult =
isToolResultMessage(message) ||
role.toLowerCase() === "toolresult" ||
role.toLowerCase() === "tool_result" ||
typeof m.toolCallId === "string" ||
typeof m.tool_call_id === "string";
const sourceRole = normalizeRoleForGrouping(role);
const normalizedMessage = normalizeMessage(message);
const normalizedRole = normalizeRoleForGrouping(normalizedMessage.role);
const isToolShell = normalizedRole === "tool";
const isStandaloneToolMessage = isStandaloneToolMessageForDisplay(message);
const toolCards = (opts.showToolCalls ?? true) ? extractToolCardsCached(message, messageKey) : [];
const hasToolCards = toolCards.length > 0;
@@ -1839,7 +1824,6 @@ function renderGroupedMessage(
const pairingQrExpiryNotices = extractPairingQrExpiryNotices(message);
const hasPairingQrExpiryNotices = pairingQrExpiryNotices.length > 0;
const normalizedMessage = normalizeMessage(message);
const extractedText = normalizedMessage.content
.reduce<string[]>((lines, item) => {
if (item.type === "text" && typeof item.text === "string") {
@@ -1884,11 +1868,10 @@ function renderGroupedMessage(
// Detect pure-JSON messages and render as collapsible block
const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null;
const isToolMessage = normalizedRole === "tool" || isToolResult;
const reserveActionSpace = hasActions && !isToolMessage;
const reserveActionSpace = hasActions && !isToolShell;
const bubbleClasses = [
"chat-bubble",
isToolMessage ? "chat-bubble--tool-shell" : "",
isToolShell ? "chat-bubble--tool-shell" : "",
hasActions ? "has-copy" : "",
reserveActionSpace ? "chat-bubble--has-actions" : "",
opts.isStreaming ? "streaming" : "",
@@ -1940,9 +1923,7 @@ function renderGroupedMessage(
: toolNames.length <= 3
? toolNames.join(", ")
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`;
const toolSummaryLabel = formatCollapsedToolSummaryText(toolSummaryLabelRaw);
const toolPreview =
markdown && !toolSummaryLabel ? (formatCollapsedToolPreviewText(markdown) ?? "") : "";
const toolPreview = markdown ? (formatCollapsedToolPreviewText(markdown) ?? "") : "";
const toolMessageLabelRaw = toolMessageHasError
? "Tool error"
: singleToolDisplayDetail && !markdown && !hasImages
@@ -1952,7 +1933,23 @@ function renderGroupedMessage(
: "Tool output";
const toolMessageLabel =
formatCollapsedToolSummaryText(toolMessageLabelRaw) ?? toolMessageLabelRaw;
const toolSummaryLabel = formatDistinctCollapsedToolSummaryText(
toolSummaryLabelRaw,
toolMessageLabel,
);
const toolMessageIcon = singleToolDisplay ? renderChatIcon(singleToolDisplay.icon) : icons.zap;
const assistantViewContent =
sourceRole === "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;
const duplicateCount = Math.max(1, Math.floor(opts.duplicateCount ?? 1));
@@ -1975,7 +1972,7 @@ function renderGroupedMessage(
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
</div>`
: nothing}
${isToolMessage
${isStandaloneToolMessage
? html`
<div
class="chat-tool-msg-collapse chat-tool-msg-collapse--manual ${toolMessageExpanded
@@ -2015,6 +2012,7 @@ function renderGroupedMessage(
opts.onRequestUpdate,
opts.onAssistantAttachmentLoaded,
)}
${assistantViewContent}
${reasoningMarkdown
? html`<div class="chat-thinking">
${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))}
</div>`
: 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`<details class="chat-json-collapse">
<summary class="chat-json-summary">
@@ -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,
@@ -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(
{
@@ -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)}`);
@@ -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`
<button
class="chat-tool-msg-summary ${isError ? "chat-tool-msg-summary--error" : ""}"
@@ -335,6 +336,7 @@ export function renderToolCard(
opts: {
expanded: boolean;
onToggleExpanded: (id: string) => void;
turnSucceeded?: boolean;
sessionKey?: string;
agentId?: string;
onOpenSidebar?: (content: SidebarContent) => void;
@@ -344,7 +346,7 @@ export function renderToolCard(
},
) {
const display = resolveToolDisplay({ name: card.name, args: card.args, detailMode: "explain" });
const isError = isToolCardError(card);
const isError = isToolCardError(card) && opts.turnSucceeded !== true;
const summary = resolveCollapsedToolSummaryParts({
card,
displayLabel: display.label,
+2 -12
View File
@@ -10,7 +10,8 @@
margin-top: 2px;
}
.chat-bubble--tool-shell > .chat-tool-msg-collapse {
.chat-bubble--tool-shell > .chat-tool-msg-collapse,
.chat-bubble--tool-shell > .chat-tools-inline > .chat-tool-msg-collapse {
margin-top: 0;
}
@@ -572,17 +573,6 @@
line-height: 1.4;
}
.chat-activity-group__preview {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
color: var(--muted);
font-size: var(--control-ui-text-sm);
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Left rule groups the rows without wrapping them in another card. */
.chat-activity-group__body {
display: flex;