From 68954f9c6ccbe923b2cd9e5c1c618e05549c4428 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Apr 2026 23:32:34 +0100 Subject: [PATCH] test: extract chat item builder coverage --- ui/src/ui/chat/build-chat-items.test.ts | 195 +++++++++++++ ui/src/ui/chat/build-chat-items.ts | 341 +++++++++++++++++++++++ ui/src/ui/views/chat.test.ts | 205 -------------- ui/src/ui/views/chat.ts | 352 +----------------------- 4 files changed, 551 insertions(+), 542 deletions(-) create mode 100644 ui/src/ui/chat/build-chat-items.test.ts create mode 100644 ui/src/ui/chat/build-chat-items.ts diff --git a/ui/src/ui/chat/build-chat-items.test.ts b/ui/src/ui/chat/build-chat-items.test.ts new file mode 100644 index 00000000000..fecdb767ced --- /dev/null +++ b/ui/src/ui/chat/build-chat-items.test.ts @@ -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 { + return { + sessionKey: "main", + messages: [], + toolMessages: [], + streamSegments: [], + stream: null, + streamStartedAt: null, + showToolCalls: true, + ...overrides, + }; +} + +function messageGroups(props: Partial): 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" + ); +} diff --git a/ui/src/ui/chat/build-chat-items.ts b/ui/src/ui/chat/build-chat-items.ts new file mode 100644 index 00000000000..1ceb80230dd --- /dev/null +++ b/ui/src/ui/chat/build-chat-items.ts @@ -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, { kind: "canvas" }>, + rawText: string | null, +) { + const raw = message as Record; + 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, { 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; + 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; + 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 { + const result: Array = []; + 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 { + 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; + const marker = raw.__openclaw as Record | 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, { 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, + 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; + 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}`; +} diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index cfbc56a3d40..85d2fbe8b4f 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -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(".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(); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 7b8f44e9b6e..e505ec300ae 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -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 { return getOrCreateSessionCacheValue(initializedToolCardsBySession, sessionKey, () => new Set()); } -function appendCanvasBlockToAssistantMessage( - message: unknown, - preview: Extract, { kind: "canvas" }>, - rawText: string | null, -) { - const raw = message as Record; - 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, { 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; - 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; - 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) { `; } - -const CHAT_HISTORY_RENDER_LIMIT = 200; - -function groupMessages(items: ChatItem[]): Array { - const result: Array = []; - 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 { - 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; - const marker = raw.__openclaw as Record | 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, { 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, - 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; - 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}`; -}