test: extract chat item builder coverage

This commit is contained in:
Peter Steinberger
2026-04-20 23:32:34 +01:00
parent 31d545260e
commit 68954f9c6c
4 changed files with 551 additions and 542 deletions

View 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"
);
}

View 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}`;
}

View File

@@ -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();

View File

@@ -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}`;
}