mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:20:44 +00:00
test: extract chat item builder coverage
This commit is contained in:
195
ui/src/ui/chat/build-chat-items.test.ts
Normal file
195
ui/src/ui/chat/build-chat-items.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MessageGroup } from "../types/chat-types.ts";
|
||||
import { buildChatItems, type BuildChatItemsProps } from "./build-chat-items.ts";
|
||||
|
||||
function createProps(overrides: Partial<BuildChatItemsProps> = {}): BuildChatItemsProps {
|
||||
return {
|
||||
sessionKey: "main",
|
||||
messages: [],
|
||||
toolMessages: [],
|
||||
streamSegments: [],
|
||||
stream: null,
|
||||
streamStartedAt: null,
|
||||
showToolCalls: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function messageGroups(props: Partial<BuildChatItemsProps>): MessageGroup[] {
|
||||
return buildChatItems(createProps(props)).filter((item) => item.kind === "group");
|
||||
}
|
||||
|
||||
function firstMessageContent(group: MessageGroup): unknown[] {
|
||||
const message = group.messages[0]?.message as { content?: unknown };
|
||||
return Array.isArray(message.content) ? message.content : [];
|
||||
}
|
||||
|
||||
describe("buildChatItems", () => {
|
||||
it("keeps consecutive user messages from different senders in separate groups", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "first",
|
||||
senderLabel: "Iris",
|
||||
timestamp: 1000,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "second",
|
||||
senderLabel: "Joaquin De Rojas",
|
||||
timestamp: 1001,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups.map((group) => group.senderLabel)).toEqual(["Iris", "Joaquin De Rojas"]);
|
||||
});
|
||||
|
||||
it("attaches lifted canvas previews to the nearest assistant turn", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [
|
||||
{
|
||||
id: "assistant-with-canvas",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "First reply." }],
|
||||
timestamp: 1_000,
|
||||
},
|
||||
{
|
||||
id: "assistant-without-canvas",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Later unrelated reply." }],
|
||||
timestamp: 2_000,
|
||||
},
|
||||
],
|
||||
toolMessages: [
|
||||
{
|
||||
id: "tool-canvas-for-first-reply",
|
||||
role: "tool",
|
||||
toolCallId: "call-canvas-old",
|
||||
toolName: "canvas_render",
|
||||
content: JSON.stringify({
|
||||
kind: "canvas",
|
||||
view: {
|
||||
backend: "canvas",
|
||||
id: "cv_nearest_turn",
|
||||
url: "/__openclaw__/canvas/documents/cv_nearest_turn/index.html",
|
||||
title: "Nearest turn demo",
|
||||
preferred_height: 320,
|
||||
},
|
||||
presentation: {
|
||||
target: "assistant_message",
|
||||
},
|
||||
}),
|
||||
timestamp: 1_001,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(firstMessageContent(groups[0]).some((block) => isCanvasBlock(block))).toBe(true);
|
||||
expect(firstMessageContent(groups[1]).some((block) => isCanvasBlock(block))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not lift generic view handles from non-canvas payloads", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [
|
||||
{
|
||||
id: "assistant-generic-inline",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Rendered the item inline." }],
|
||||
timestamp: 1000,
|
||||
},
|
||||
],
|
||||
toolMessages: [
|
||||
{
|
||||
id: "tool-generic-inline",
|
||||
role: "tool",
|
||||
toolCallId: "call-generic-inline",
|
||||
toolName: "plugin_card_details",
|
||||
content: JSON.stringify({
|
||||
selected_item: {
|
||||
summary: {
|
||||
label: "Alpha",
|
||||
meaning: "Generic example",
|
||||
},
|
||||
view: {
|
||||
backend: "canvas",
|
||||
id: "cv_generic_inline",
|
||||
url: "/__openclaw__/canvas/documents/cv_generic_inline/index.html",
|
||||
title: "Inline generic preview",
|
||||
preferred_height: 420,
|
||||
},
|
||||
},
|
||||
}),
|
||||
timestamp: 1001,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(firstMessageContent(groups[0]).some((block) => isCanvasBlock(block))).toBe(false);
|
||||
});
|
||||
|
||||
it("lifts streamed canvas toolresult blocks into the assistant bubble", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [
|
||||
{
|
||||
id: "assistant-streamed-artifact",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Done." }],
|
||||
timestamp: 1000,
|
||||
},
|
||||
],
|
||||
toolMessages: [
|
||||
{
|
||||
id: "tool-streamed-artifact",
|
||||
role: "assistant",
|
||||
toolCallId: "call_streamed_artifact",
|
||||
timestamp: 999,
|
||||
content: [
|
||||
{
|
||||
type: "toolcall",
|
||||
name: "canvas_render",
|
||||
arguments: { source: { type: "handle", id: "cv_streamed_artifact" } },
|
||||
},
|
||||
{
|
||||
type: "toolresult",
|
||||
name: "canvas_render",
|
||||
text: JSON.stringify({
|
||||
kind: "canvas",
|
||||
view: {
|
||||
backend: "canvas",
|
||||
id: "cv_streamed_artifact",
|
||||
url: "/__openclaw__/canvas/documents/cv_streamed_artifact/index.html",
|
||||
title: "Streamed demo",
|
||||
preferred_height: 320,
|
||||
},
|
||||
presentation: {
|
||||
target: "assistant_message",
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const canvasBlocks = firstMessageContent(groups[0]).filter((block) => isCanvasBlock(block));
|
||||
expect(canvasBlocks).toHaveLength(1);
|
||||
expect(canvasBlocks[0]).toMatchObject({
|
||||
preview: {
|
||||
viewId: "cv_streamed_artifact",
|
||||
title: "Streamed demo",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function isCanvasBlock(block: unknown): boolean {
|
||||
return (
|
||||
Boolean(block) &&
|
||||
typeof block === "object" &&
|
||||
(block as { type?: unknown; preview?: { kind?: unknown } }).type === "canvas" &&
|
||||
(block as { preview?: { kind?: unknown } }).preview?.kind === "canvas"
|
||||
);
|
||||
}
|
||||
341
ui/src/ui/chat/build-chat-items.ts
Normal file
341
ui/src/ui/chat/build-chat-items.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import type { ChatItem, MessageGroup, ToolCard } from "../types/chat-types.ts";
|
||||
import { extractTextCached } from "./message-extract.ts";
|
||||
import { normalizeMessage, normalizeRoleForGrouping } from "./message-normalizer.ts";
|
||||
import { messageMatchesSearchQuery } from "./search-match.ts";
|
||||
import { extractToolCards, extractToolPreview } from "./tool-cards.ts";
|
||||
|
||||
const CHAT_HISTORY_RENDER_LIMIT = 200;
|
||||
|
||||
export type BuildChatItemsProps = {
|
||||
sessionKey: string;
|
||||
messages: unknown[];
|
||||
toolMessages: unknown[];
|
||||
streamSegments: Array<{ text: string; ts: number }>;
|
||||
stream: string | null;
|
||||
streamStartedAt: number | null;
|
||||
showToolCalls: boolean;
|
||||
searchOpen?: boolean;
|
||||
searchQuery?: string;
|
||||
};
|
||||
|
||||
function appendCanvasBlockToAssistantMessage(
|
||||
message: unknown,
|
||||
preview: Extract<NonNullable<ToolCard["preview"]>, { kind: "canvas" }>,
|
||||
rawText: string | null,
|
||||
) {
|
||||
const raw = message as Record<string, unknown>;
|
||||
const existingContent = Array.isArray(raw.content)
|
||||
? [...raw.content]
|
||||
: typeof raw.content === "string"
|
||||
? [{ type: "text", text: raw.content }]
|
||||
: typeof raw.text === "string"
|
||||
? [{ type: "text", text: raw.text }]
|
||||
: [];
|
||||
const alreadyHasArtifact = existingContent.some((block) => {
|
||||
if (!block || typeof block !== "object") {
|
||||
return false;
|
||||
}
|
||||
const typed = block as {
|
||||
type?: unknown;
|
||||
preview?: { kind?: unknown; viewId?: unknown; url?: unknown };
|
||||
};
|
||||
return (
|
||||
typed.type === "canvas" &&
|
||||
typed.preview?.kind === "canvas" &&
|
||||
((preview.viewId && typed.preview.viewId === preview.viewId) ||
|
||||
(preview.url && typed.preview.url === preview.url))
|
||||
);
|
||||
});
|
||||
if (alreadyHasArtifact) {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...raw,
|
||||
content: [
|
||||
...existingContent,
|
||||
{
|
||||
type: "canvas",
|
||||
preview,
|
||||
...(rawText ? { rawText } : {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function extractChatMessagePreview(toolMessage: unknown): {
|
||||
preview: Extract<NonNullable<ToolCard["preview"]>, { kind: "canvas" }>;
|
||||
text: string | null;
|
||||
timestamp: number | null;
|
||||
} | null {
|
||||
const normalized = normalizeMessage(toolMessage);
|
||||
const cards = extractToolCards(toolMessage, "preview");
|
||||
for (let index = cards.length - 1; index >= 0; index--) {
|
||||
const card = cards[index];
|
||||
if (card?.preview?.kind === "canvas") {
|
||||
return {
|
||||
preview: card.preview,
|
||||
text: card.outputText ?? null,
|
||||
timestamp: normalized.timestamp ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
const text = extractTextCached(toolMessage) ?? undefined;
|
||||
const toolRecord = toolMessage as Record<string, unknown>;
|
||||
const toolName =
|
||||
typeof toolRecord.toolName === "string"
|
||||
? toolRecord.toolName
|
||||
: typeof toolRecord.tool_name === "string"
|
||||
? toolRecord.tool_name
|
||||
: undefined;
|
||||
const preview = extractToolPreview(text, toolName);
|
||||
if (preview?.kind !== "canvas") {
|
||||
return null;
|
||||
}
|
||||
return { preview, text: text ?? null, timestamp: normalized.timestamp ?? null };
|
||||
}
|
||||
|
||||
function findNearestAssistantMessageIndex(
|
||||
items: ChatItem[],
|
||||
toolTimestamp: number | null,
|
||||
): number | null {
|
||||
const assistantEntries = items
|
||||
.map((item, index) => {
|
||||
if (item.kind !== "message") {
|
||||
return null;
|
||||
}
|
||||
const message = item.message as Record<string, unknown>;
|
||||
const role = typeof message.role === "string" ? message.role.toLowerCase() : "";
|
||||
if (role !== "assistant") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
index,
|
||||
timestamp: normalizeMessage(item.message).timestamp ?? null,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{ index: number; timestamp: number | null }>;
|
||||
if (assistantEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (toolTimestamp == null) {
|
||||
return assistantEntries[assistantEntries.length - 1]?.index ?? null;
|
||||
}
|
||||
let previous: { index: number; timestamp: number } | null = null;
|
||||
let next: { index: number; timestamp: number } | null = null;
|
||||
for (const entry of assistantEntries) {
|
||||
if (entry.timestamp == null) {
|
||||
continue;
|
||||
}
|
||||
if (entry.timestamp <= toolTimestamp) {
|
||||
previous = { index: entry.index, timestamp: entry.timestamp };
|
||||
continue;
|
||||
}
|
||||
next = { index: entry.index, timestamp: entry.timestamp };
|
||||
break;
|
||||
}
|
||||
if (previous && next) {
|
||||
const previousDelta = toolTimestamp - previous.timestamp;
|
||||
const nextDelta = next.timestamp - toolTimestamp;
|
||||
return nextDelta < previousDelta ? next.index : previous.index;
|
||||
}
|
||||
if (previous) {
|
||||
return previous.index;
|
||||
}
|
||||
if (next) {
|
||||
return next.index;
|
||||
}
|
||||
return assistantEntries[assistantEntries.length - 1]?.index ?? null;
|
||||
}
|
||||
|
||||
function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
|
||||
const result: Array<ChatItem | MessageGroup> = [];
|
||||
let currentGroup: MessageGroup | null = null;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.kind !== "message") {
|
||||
if (currentGroup) {
|
||||
result.push(currentGroup);
|
||||
currentGroup = null;
|
||||
}
|
||||
result.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = normalizeMessage(item.message);
|
||||
const role = normalizeRoleForGrouping(normalized.role);
|
||||
const senderLabel = role.toLowerCase() === "user" ? (normalized.senderLabel ?? null) : null;
|
||||
const timestamp = normalized.timestamp || Date.now();
|
||||
|
||||
if (
|
||||
!currentGroup ||
|
||||
currentGroup.role !== role ||
|
||||
(role.toLowerCase() === "user" && currentGroup.senderLabel !== senderLabel)
|
||||
) {
|
||||
if (currentGroup) {
|
||||
result.push(currentGroup);
|
||||
}
|
||||
currentGroup = {
|
||||
kind: "group",
|
||||
key: `group:${role}:${item.key}`,
|
||||
role,
|
||||
senderLabel,
|
||||
messages: [{ message: item.message, key: item.key }],
|
||||
timestamp,
|
||||
isStreaming: false,
|
||||
};
|
||||
} else {
|
||||
currentGroup.messages.push({ message: item.message, key: item.key });
|
||||
}
|
||||
}
|
||||
|
||||
if (currentGroup) {
|
||||
result.push(currentGroup);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGroup> {
|
||||
const items: ChatItem[] = [];
|
||||
const history = Array.isArray(props.messages) ? props.messages : [];
|
||||
const tools = Array.isArray(props.toolMessages) ? props.toolMessages : [];
|
||||
const historyStart = Math.max(0, history.length - CHAT_HISTORY_RENDER_LIMIT);
|
||||
if (historyStart > 0) {
|
||||
items.push({
|
||||
kind: "message",
|
||||
key: "chat:history:notice",
|
||||
message: {
|
||||
role: "system",
|
||||
content: `Showing last ${CHAT_HISTORY_RENDER_LIMIT} messages (${historyStart} hidden).`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
for (let i = historyStart; i < history.length; i++) {
|
||||
const msg = history[i];
|
||||
const normalized = normalizeMessage(msg);
|
||||
const raw = msg as Record<string, unknown>;
|
||||
const marker = raw.__openclaw as Record<string, unknown> | undefined;
|
||||
if (marker && marker.kind === "compaction") {
|
||||
items.push({
|
||||
kind: "divider",
|
||||
key:
|
||||
typeof marker.id === "string"
|
||||
? `divider:compaction:${marker.id}`
|
||||
: `divider:compaction:${normalized.timestamp}:${i}`,
|
||||
label: "Compaction",
|
||||
timestamp: normalized.timestamp ?? Date.now(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!props.showToolCalls && normalized.role.toLowerCase() === "toolresult") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const searchQuery = props.searchQuery ?? "";
|
||||
if (props.searchOpen && searchQuery.trim() && !messageMatchesSearchQuery(msg, searchQuery)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
kind: "message",
|
||||
key: messageKey(msg, i),
|
||||
message: msg,
|
||||
});
|
||||
}
|
||||
const liftedCanvasSources = tools
|
||||
.map((tool) => extractChatMessagePreview(tool))
|
||||
.filter((entry) => Boolean(entry)) as Array<{
|
||||
preview: Extract<NonNullable<ToolCard["preview"]>, { kind: "canvas" }>;
|
||||
text: string | null;
|
||||
timestamp: number | null;
|
||||
}>;
|
||||
for (const liftedCanvasSource of liftedCanvasSources) {
|
||||
const assistantIndex = findNearestAssistantMessageIndex(items, liftedCanvasSource.timestamp);
|
||||
if (assistantIndex == null) {
|
||||
continue;
|
||||
}
|
||||
const item = items[assistantIndex];
|
||||
if (!item || item.kind !== "message") {
|
||||
continue;
|
||||
}
|
||||
items[assistantIndex] = {
|
||||
...item,
|
||||
message: appendCanvasBlockToAssistantMessage(
|
||||
item.message as Record<string, unknown>,
|
||||
liftedCanvasSource.preview,
|
||||
liftedCanvasSource.text,
|
||||
),
|
||||
};
|
||||
}
|
||||
const segments = props.streamSegments ?? [];
|
||||
const maxLen = Math.max(segments.length, tools.length);
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
if (i < segments.length && segments[i].text.trim().length > 0) {
|
||||
items.push({
|
||||
kind: "stream",
|
||||
key: `stream-seg:${props.sessionKey}:${i}`,
|
||||
text: segments[i].text,
|
||||
startedAt: segments[i].ts,
|
||||
});
|
||||
}
|
||||
if (i < tools.length && props.showToolCalls) {
|
||||
items.push({
|
||||
kind: "message",
|
||||
key: messageKey(tools[i], i + history.length),
|
||||
message: tools[i],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (props.stream !== null) {
|
||||
const key = `stream:${props.sessionKey}:${props.streamStartedAt ?? "live"}`;
|
||||
if (props.stream.trim().length > 0) {
|
||||
items.push({
|
||||
kind: "stream",
|
||||
key,
|
||||
text: props.stream,
|
||||
startedAt: props.streamStartedAt ?? Date.now(),
|
||||
});
|
||||
} else {
|
||||
items.push({ kind: "reading-indicator", key });
|
||||
}
|
||||
}
|
||||
|
||||
return groupMessages(items);
|
||||
}
|
||||
|
||||
function messageKey(message: unknown, index: number): string {
|
||||
const m = message as Record<string, unknown>;
|
||||
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
|
||||
if (toolCallId) {
|
||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
||||
const id = typeof m.id === "string" ? m.id : "";
|
||||
if (id) {
|
||||
return `tool:${role}:${toolCallId}:${id}`;
|
||||
}
|
||||
const messageId = typeof m.messageId === "string" ? m.messageId : "";
|
||||
if (messageId) {
|
||||
return `tool:${role}:${toolCallId}:${messageId}`;
|
||||
}
|
||||
const timestamp = typeof m.timestamp === "number" ? m.timestamp : null;
|
||||
if (timestamp != null) {
|
||||
return `tool:${role}:${toolCallId}:${timestamp}:${index}`;
|
||||
}
|
||||
return `tool:${role}:${toolCallId}:${index}`;
|
||||
}
|
||||
const id = typeof m.id === "string" ? m.id : "";
|
||||
if (id) {
|
||||
return `msg:${id}`;
|
||||
}
|
||||
const messageId = typeof m.messageId === "string" ? m.messageId : "";
|
||||
if (messageId) {
|
||||
return `msg:${messageId}`;
|
||||
}
|
||||
const timestamp = typeof m.timestamp === "number" ? m.timestamp : null;
|
||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
||||
if (timestamp != null) {
|
||||
return `msg:${role}:${timestamp}:${index}`;
|
||||
}
|
||||
return `msg:${role}:${index}`;
|
||||
}
|
||||
@@ -223,40 +223,6 @@ describe("chat view", () => {
|
||||
expect(container.textContent).not.toContain("Stop");
|
||||
});
|
||||
|
||||
it("keeps consecutive user messages from different senders in separate groups", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "first",
|
||||
senderLabel: "Iris",
|
||||
timestamp: 1000,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "second",
|
||||
senderLabel: "Joaquin De Rojas",
|
||||
timestamp: 1001,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const groups = container.querySelectorAll(".chat-group.user");
|
||||
expect(groups).toHaveLength(2);
|
||||
const senderLabels = Array.from(container.querySelectorAll(".chat-sender-name")).map((node) =>
|
||||
node.textContent?.trim(),
|
||||
);
|
||||
expect(senderLabels).toContain("Iris");
|
||||
expect(senderLabels).toContain("Joaquin De Rojas");
|
||||
expect(senderLabels).not.toContain("You");
|
||||
});
|
||||
|
||||
it("positions delete confirm by message side", () => {
|
||||
clearDeleteConfirmSkip();
|
||||
const container = document.createElement("div");
|
||||
@@ -463,177 +429,6 @@ describe("chat view", () => {
|
||||
expect(container.textContent).toContain("Inline demo");
|
||||
});
|
||||
|
||||
it("keeps lifted canvas previews attached to the nearest assistant turn", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
showToolCalls: true,
|
||||
messages: [
|
||||
{
|
||||
id: "assistant-with-canvas",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "First reply." }],
|
||||
timestamp: 1_000,
|
||||
},
|
||||
{
|
||||
id: "assistant-without-canvas",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Later unrelated reply." }],
|
||||
timestamp: 2_000,
|
||||
},
|
||||
],
|
||||
toolMessages: [
|
||||
{
|
||||
id: "tool-canvas-for-first-reply",
|
||||
role: "tool",
|
||||
toolCallId: "call-canvas-old",
|
||||
toolName: "canvas_render",
|
||||
content: JSON.stringify({
|
||||
kind: "canvas",
|
||||
view: {
|
||||
backend: "canvas",
|
||||
id: "cv_nearest_turn",
|
||||
url: "/__openclaw__/canvas/documents/cv_nearest_turn/index.html",
|
||||
title: "Nearest turn demo",
|
||||
preferred_height: 320,
|
||||
},
|
||||
presentation: {
|
||||
target: "assistant_message",
|
||||
},
|
||||
}),
|
||||
timestamp: 1_001,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const assistantBubbles = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(".chat-group.assistant .chat-bubble"),
|
||||
);
|
||||
expect(assistantBubbles).toHaveLength(2);
|
||||
expect(assistantBubbles[0]?.querySelector(".chat-tool-card__preview-frame")).not.toBeNull();
|
||||
expect(assistantBubbles[1]?.querySelector(".chat-tool-card__preview-frame")).toBeNull();
|
||||
expect(assistantBubbles[1]?.textContent).toContain("Later unrelated reply.");
|
||||
});
|
||||
|
||||
it("does not auto-render generic view handles from non-canvas payloads", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
showToolCalls: true,
|
||||
messages: [
|
||||
{
|
||||
id: "assistant-generic-inline",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Rendered the item inline." }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
toolMessages: [
|
||||
{
|
||||
id: "tool-generic-inline",
|
||||
role: "tool",
|
||||
toolCallId: "call-generic-inline",
|
||||
toolName: "plugin_card_details",
|
||||
content: JSON.stringify({
|
||||
selected_item: {
|
||||
summary: {
|
||||
label: "Alpha",
|
||||
meaning: "Generic example",
|
||||
},
|
||||
view: {
|
||||
backend: "canvas",
|
||||
id: "cv_generic_inline",
|
||||
url: "/__openclaw__/canvas/documents/cv_generic_inline/index.html",
|
||||
title: "Inline generic preview",
|
||||
preferred_height: 420,
|
||||
},
|
||||
},
|
||||
}),
|
||||
timestamp: Date.now() + 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const assistantBubble = container.querySelector(".chat-group.assistant .chat-bubble");
|
||||
const allPreviews = container.querySelectorAll(".chat-tool-card__preview-frame");
|
||||
expect(allPreviews).toHaveLength(0);
|
||||
expect(assistantBubble?.querySelector(".chat-tool-card__preview-frame")).toBeNull();
|
||||
expect(container.textContent).toContain("Tool output");
|
||||
expect(container.textContent).toContain("plugin_card_details");
|
||||
expect(container.textContent).toContain("Rendered the item inline.");
|
||||
expect(container.textContent).not.toContain("Inline generic preview");
|
||||
});
|
||||
|
||||
it("lifts streamed canvas tool messages with toolresult blocks into the assistant bubble", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
showToolCalls: true,
|
||||
messages: [
|
||||
{
|
||||
id: "assistant-streamed-artifact",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Done." }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
toolMessages: [
|
||||
{
|
||||
id: "tool-streamed-artifact",
|
||||
role: "assistant",
|
||||
toolCallId: "call_streamed_artifact",
|
||||
timestamp: Date.now() - 1,
|
||||
content: [
|
||||
{
|
||||
type: "toolcall",
|
||||
name: "canvas_render",
|
||||
arguments: { source: { type: "handle", id: "cv_streamed_artifact" } },
|
||||
},
|
||||
{
|
||||
type: "toolresult",
|
||||
name: "canvas_render",
|
||||
text: JSON.stringify({
|
||||
kind: "canvas",
|
||||
view: {
|
||||
backend: "canvas",
|
||||
id: "cv_streamed_artifact",
|
||||
url: "/__openclaw__/canvas/documents/cv_streamed_artifact/index.html",
|
||||
title: "Streamed demo",
|
||||
preferred_height: 320,
|
||||
},
|
||||
presentation: {
|
||||
target: "assistant_message",
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const assistantBubble = container.querySelector(".chat-group.assistant .chat-bubble");
|
||||
expect(assistantBubble?.querySelector(".chat-tool-card__preview-frame")).not.toBeNull();
|
||||
expect(container.textContent).toContain("Streamed demo");
|
||||
expect(container.textContent).toContain("Done.");
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".chat-tool-msg-summary__label")).map((node) =>
|
||||
node.textContent?.trim(),
|
||||
),
|
||||
).toContain("Tool output");
|
||||
});
|
||||
|
||||
it("opens generic tool details instead of a canvas preview from tool rows", async () => {
|
||||
const container = document.createElement("div");
|
||||
const onOpenSidebar = vi.fn();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CHAT_ATTACHMENT_ACCEPT,
|
||||
isSupportedChatAttachmentMimeType,
|
||||
} from "../chat/attachment-support.ts";
|
||||
import { buildChatItems } from "../chat/build-chat-items.ts";
|
||||
import { renderContextNotice } from "../chat/context-notice.ts";
|
||||
import { DeletedMessages } from "../chat/deleted-messages.ts";
|
||||
import { exportChatMarkdown } from "../chat/export.ts";
|
||||
@@ -15,15 +16,9 @@ import {
|
||||
renderStreamingGroup,
|
||||
} from "../chat/grouped-render.ts";
|
||||
import { InputHistory } from "../chat/input-history.ts";
|
||||
import { extractTextCached } from "../chat/message-extract.ts";
|
||||
import {
|
||||
isToolResultMessage,
|
||||
normalizeMessage,
|
||||
normalizeRoleForGrouping,
|
||||
} from "../chat/message-normalizer.ts";
|
||||
import { isToolResultMessage, normalizeRoleForGrouping } from "../chat/message-normalizer.ts";
|
||||
import { PinnedMessages } from "../chat/pinned-messages.ts";
|
||||
import { getPinnedMessageSummary } from "../chat/pinned-summary.ts";
|
||||
import { messageMatchesSearchQuery } from "../chat/search-match.ts";
|
||||
import { getOrCreateSessionCacheValue } from "../chat/session-cache.ts";
|
||||
import { renderSideResult } from "../chat/side-result-render.ts";
|
||||
import type { ChatSideResult } from "../chat/side-result.ts";
|
||||
@@ -36,13 +31,13 @@ import {
|
||||
type SlashCommandDef,
|
||||
} from "../chat/slash-commands.ts";
|
||||
import { isSttSupported, startStt, stopStt } from "../chat/speech.ts";
|
||||
import { buildSidebarContent, extractToolCards, extractToolPreview } from "../chat/tool-cards.ts";
|
||||
import { buildSidebarContent, extractToolCards } from "../chat/tool-cards.ts";
|
||||
import type { EmbedSandboxMode } from "../embed-sandbox.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
import type { SidebarContent } from "../sidebar-content.ts";
|
||||
import { detectTextDirection } from "../text-direction.ts";
|
||||
import type { SessionsListResult } from "../types.ts";
|
||||
import type { ChatItem, MessageGroup, ToolCard } from "../types/chat-types.ts";
|
||||
import type { ChatItem, MessageGroup } from "../types/chat-types.ts";
|
||||
import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts";
|
||||
import { agentLogoUrl, resolveAgentAvatarUrl } from "./agents-utils.ts";
|
||||
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
|
||||
@@ -155,135 +150,6 @@ function getInitializedToolCards(sessionKey: string): Set<string> {
|
||||
return getOrCreateSessionCacheValue(initializedToolCardsBySession, sessionKey, () => new Set());
|
||||
}
|
||||
|
||||
function appendCanvasBlockToAssistantMessage(
|
||||
message: unknown,
|
||||
preview: Extract<NonNullable<ToolCard["preview"]>, { kind: "canvas" }>,
|
||||
rawText: string | null,
|
||||
) {
|
||||
const raw = message as Record<string, unknown>;
|
||||
const existingContent = Array.isArray(raw.content)
|
||||
? [...raw.content]
|
||||
: typeof raw.content === "string"
|
||||
? [{ type: "text", text: raw.content }]
|
||||
: typeof raw.text === "string"
|
||||
? [{ type: "text", text: raw.text }]
|
||||
: [];
|
||||
const alreadyHasArtifact = existingContent.some((block) => {
|
||||
if (!block || typeof block !== "object") {
|
||||
return false;
|
||||
}
|
||||
const typed = block as {
|
||||
type?: unknown;
|
||||
preview?: { kind?: unknown; viewId?: unknown; url?: unknown };
|
||||
};
|
||||
return (
|
||||
typed.type === "canvas" &&
|
||||
typed.preview?.kind === "canvas" &&
|
||||
((preview.viewId && typed.preview.viewId === preview.viewId) ||
|
||||
(preview.url && typed.preview.url === preview.url))
|
||||
);
|
||||
});
|
||||
if (alreadyHasArtifact) {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...raw,
|
||||
content: [
|
||||
...existingContent,
|
||||
{
|
||||
type: "canvas",
|
||||
preview,
|
||||
...(rawText ? { rawText } : {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function extractChatMessagePreview(toolMessage: unknown): {
|
||||
preview: Extract<NonNullable<ToolCard["preview"]>, { kind: "canvas" }>;
|
||||
text: string | null;
|
||||
timestamp: number | null;
|
||||
} | null {
|
||||
const normalized = normalizeMessage(toolMessage);
|
||||
const cards = extractToolCards(toolMessage, "preview");
|
||||
for (let index = cards.length - 1; index >= 0; index--) {
|
||||
const card = cards[index];
|
||||
if (card?.preview?.kind === "canvas") {
|
||||
return {
|
||||
preview: card.preview,
|
||||
text: card.outputText ?? null,
|
||||
timestamp: normalized.timestamp ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
const text = extractTextCached(toolMessage) ?? undefined;
|
||||
const toolRecord = toolMessage as Record<string, unknown>;
|
||||
const toolName =
|
||||
typeof toolRecord.toolName === "string"
|
||||
? toolRecord.toolName
|
||||
: typeof toolRecord.tool_name === "string"
|
||||
? toolRecord.tool_name
|
||||
: undefined;
|
||||
const preview = extractToolPreview(text, toolName);
|
||||
if (preview?.kind !== "canvas") {
|
||||
return null;
|
||||
}
|
||||
return { preview, text: text ?? null, timestamp: normalized.timestamp ?? null };
|
||||
}
|
||||
|
||||
function findNearestAssistantMessageIndex(
|
||||
items: ChatItem[],
|
||||
toolTimestamp: number | null,
|
||||
): number | null {
|
||||
const assistantEntries = items
|
||||
.map((item, index) => {
|
||||
if (item.kind !== "message") {
|
||||
return null;
|
||||
}
|
||||
const message = item.message as Record<string, unknown>;
|
||||
const role = typeof message.role === "string" ? message.role.toLowerCase() : "";
|
||||
if (role !== "assistant") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
index,
|
||||
timestamp: normalizeMessage(item.message).timestamp ?? null,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{ index: number; timestamp: number | null }>;
|
||||
if (assistantEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (toolTimestamp == null) {
|
||||
return assistantEntries[assistantEntries.length - 1]?.index ?? null;
|
||||
}
|
||||
let previous: { index: number; timestamp: number } | null = null;
|
||||
let next: { index: number; timestamp: number } | null = null;
|
||||
for (const entry of assistantEntries) {
|
||||
if (entry.timestamp == null) {
|
||||
continue;
|
||||
}
|
||||
if (entry.timestamp <= toolTimestamp) {
|
||||
previous = { index: entry.index, timestamp: entry.timestamp };
|
||||
continue;
|
||||
}
|
||||
next = { index: entry.index, timestamp: entry.timestamp };
|
||||
break;
|
||||
}
|
||||
if (previous && next) {
|
||||
const previousDelta = toolTimestamp - previous.timestamp;
|
||||
const nextDelta = next.timestamp - toolTimestamp;
|
||||
return nextDelta < previousDelta ? next.index : previous.index;
|
||||
}
|
||||
if (previous) {
|
||||
return previous.index;
|
||||
}
|
||||
if (next) {
|
||||
return next.index;
|
||||
}
|
||||
return assistantEntries[assistantEntries.length - 1]?.index ?? null;
|
||||
}
|
||||
|
||||
interface ChatEphemeralState {
|
||||
sttRecording: boolean;
|
||||
sttInterimText: string;
|
||||
@@ -1053,7 +919,17 @@ export function renderChat(props: ChatProps) {
|
||||
);
|
||||
};
|
||||
|
||||
const chatItems = buildChatItems(props);
|
||||
const chatItems = buildChatItems({
|
||||
sessionKey: props.sessionKey,
|
||||
messages: props.messages,
|
||||
toolMessages: props.toolMessages,
|
||||
streamSegments: props.streamSegments,
|
||||
stream: props.stream,
|
||||
streamStartedAt: props.streamStartedAt,
|
||||
showToolCalls: props.showToolCalls,
|
||||
searchOpen: vs.searchOpen,
|
||||
searchQuery: vs.searchQuery,
|
||||
});
|
||||
syncToolCardExpansionState(props.sessionKey, chatItems, Boolean(props.autoExpandToolCalls));
|
||||
const expandedToolCards = getExpandedToolCards(props.sessionKey);
|
||||
const toggleToolCardExpanded = (toolCardId: string) => {
|
||||
@@ -1565,201 +1441,3 @@ export function renderChat(props: ChatProps) {
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
const CHAT_HISTORY_RENDER_LIMIT = 200;
|
||||
|
||||
function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
|
||||
const result: Array<ChatItem | MessageGroup> = [];
|
||||
let currentGroup: MessageGroup | null = null;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.kind !== "message") {
|
||||
if (currentGroup) {
|
||||
result.push(currentGroup);
|
||||
currentGroup = null;
|
||||
}
|
||||
result.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = normalizeMessage(item.message);
|
||||
const role = normalizeRoleForGrouping(normalized.role);
|
||||
const senderLabel = role.toLowerCase() === "user" ? (normalized.senderLabel ?? null) : null;
|
||||
const timestamp = normalized.timestamp || Date.now();
|
||||
|
||||
if (
|
||||
!currentGroup ||
|
||||
currentGroup.role !== role ||
|
||||
(role.toLowerCase() === "user" && currentGroup.senderLabel !== senderLabel)
|
||||
) {
|
||||
if (currentGroup) {
|
||||
result.push(currentGroup);
|
||||
}
|
||||
currentGroup = {
|
||||
kind: "group",
|
||||
key: `group:${role}:${item.key}`,
|
||||
role,
|
||||
senderLabel,
|
||||
messages: [{ message: item.message, key: item.key }],
|
||||
timestamp,
|
||||
isStreaming: false,
|
||||
};
|
||||
} else {
|
||||
currentGroup.messages.push({ message: item.message, key: item.key });
|
||||
}
|
||||
}
|
||||
|
||||
if (currentGroup) {
|
||||
result.push(currentGroup);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
|
||||
const items: ChatItem[] = [];
|
||||
const history = Array.isArray(props.messages) ? props.messages : [];
|
||||
const tools = Array.isArray(props.toolMessages) ? props.toolMessages : [];
|
||||
const historyStart = Math.max(0, history.length - CHAT_HISTORY_RENDER_LIMIT);
|
||||
if (historyStart > 0) {
|
||||
items.push({
|
||||
kind: "message",
|
||||
key: "chat:history:notice",
|
||||
message: {
|
||||
role: "system",
|
||||
content: `Showing last ${CHAT_HISTORY_RENDER_LIMIT} messages (${historyStart} hidden).`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
for (let i = historyStart; i < history.length; i++) {
|
||||
const msg = history[i];
|
||||
const normalized = normalizeMessage(msg);
|
||||
const raw = msg as Record<string, unknown>;
|
||||
const marker = raw.__openclaw as Record<string, unknown> | undefined;
|
||||
if (marker && marker.kind === "compaction") {
|
||||
items.push({
|
||||
kind: "divider",
|
||||
key:
|
||||
typeof marker.id === "string"
|
||||
? `divider:compaction:${marker.id}`
|
||||
: `divider:compaction:${normalized.timestamp}:${i}`,
|
||||
label: "Compaction",
|
||||
timestamp: normalized.timestamp ?? Date.now(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!props.showToolCalls && normalized.role.toLowerCase() === "toolresult") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply search filter if active
|
||||
if (vs.searchOpen && vs.searchQuery.trim() && !messageMatchesSearchQuery(msg, vs.searchQuery)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
kind: "message",
|
||||
key: messageKey(msg, i),
|
||||
message: msg,
|
||||
});
|
||||
}
|
||||
const liftedCanvasSources = tools
|
||||
.map((tool) => extractChatMessagePreview(tool))
|
||||
.filter((entry) => Boolean(entry)) as Array<{
|
||||
preview: Extract<NonNullable<ToolCard["preview"]>, { kind: "canvas" }>;
|
||||
text: string | null;
|
||||
timestamp: number | null;
|
||||
}>;
|
||||
for (const liftedCanvasSource of liftedCanvasSources) {
|
||||
const assistantIndex = findNearestAssistantMessageIndex(items, liftedCanvasSource.timestamp);
|
||||
if (assistantIndex == null) {
|
||||
continue;
|
||||
}
|
||||
const item = items[assistantIndex];
|
||||
if (!item || item.kind !== "message") {
|
||||
continue;
|
||||
}
|
||||
items[assistantIndex] = {
|
||||
...item,
|
||||
message: appendCanvasBlockToAssistantMessage(
|
||||
item.message as Record<string, unknown>,
|
||||
liftedCanvasSource.preview,
|
||||
liftedCanvasSource.text,
|
||||
),
|
||||
};
|
||||
}
|
||||
// Interleave stream segments and tool cards in order. Each segment
|
||||
// contains text that was streaming before the corresponding tool started.
|
||||
// This ensures correct visual ordering: text → tool → text → tool → ...
|
||||
const segments = props.streamSegments ?? [];
|
||||
const maxLen = Math.max(segments.length, tools.length);
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
if (i < segments.length && segments[i].text.trim().length > 0) {
|
||||
items.push({
|
||||
kind: "stream" as const,
|
||||
key: `stream-seg:${props.sessionKey}:${i}`,
|
||||
text: segments[i].text,
|
||||
startedAt: segments[i].ts,
|
||||
});
|
||||
}
|
||||
if (i < tools.length && props.showToolCalls) {
|
||||
items.push({
|
||||
kind: "message",
|
||||
key: messageKey(tools[i], i + history.length),
|
||||
message: tools[i],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (props.stream !== null) {
|
||||
const key = `stream:${props.sessionKey}:${props.streamStartedAt ?? "live"}`;
|
||||
if (props.stream.trim().length > 0) {
|
||||
items.push({
|
||||
kind: "stream",
|
||||
key,
|
||||
text: props.stream,
|
||||
startedAt: props.streamStartedAt ?? Date.now(),
|
||||
});
|
||||
} else {
|
||||
items.push({ kind: "reading-indicator", key });
|
||||
}
|
||||
}
|
||||
|
||||
return groupMessages(items);
|
||||
}
|
||||
|
||||
function messageKey(message: unknown, index: number): string {
|
||||
const m = message as Record<string, unknown>;
|
||||
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
|
||||
if (toolCallId) {
|
||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
||||
const id = typeof m.id === "string" ? m.id : "";
|
||||
if (id) {
|
||||
return `tool:${role}:${toolCallId}:${id}`;
|
||||
}
|
||||
const messageId = typeof m.messageId === "string" ? m.messageId : "";
|
||||
if (messageId) {
|
||||
return `tool:${role}:${toolCallId}:${messageId}`;
|
||||
}
|
||||
const timestamp = typeof m.timestamp === "number" ? m.timestamp : null;
|
||||
if (timestamp != null) {
|
||||
return `tool:${role}:${toolCallId}:${timestamp}:${index}`;
|
||||
}
|
||||
return `tool:${role}:${toolCallId}:${index}`;
|
||||
}
|
||||
const id = typeof m.id === "string" ? m.id : "";
|
||||
if (id) {
|
||||
return `msg:${id}`;
|
||||
}
|
||||
const messageId = typeof m.messageId === "string" ? m.messageId : "";
|
||||
if (messageId) {
|
||||
return `msg:${messageId}`;
|
||||
}
|
||||
const timestamp = typeof m.timestamp === "number" ? m.timestamp : null;
|
||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
||||
if (timestamp != null) {
|
||||
return `msg:${role}:${timestamp}:${index}`;
|
||||
}
|
||||
return `msg:${role}:${index}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user