From 2413c0f5a50d2bba3c83fa3e605ea90ae6358af7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 14:06:12 +0100 Subject: [PATCH] perf: split chat UI test dependencies --- ui/src/ui/chat/chat-avatar.test.ts | 101 ++++++++++ ui/src/ui/chat/chat-avatar.ts | 127 ++++++++++++ ui/src/ui/chat/chat-queue.ts | 67 ++++++ ui/src/ui/chat/chat-sidebar-raw.ts | 29 +++ ui/src/ui/chat/chat-welcome.ts | 88 ++++++++ ui/src/ui/chat/grouped-render.test.ts | 155 ++++---------- ui/src/ui/chat/grouped-render.ts | 132 +----------- ui/src/ui/chat/session-controls.test.ts | 55 ++--- ui/src/ui/chat/session-controls.ts | 2 +- ui/src/ui/chat/tool-cards.test.ts | 64 +++--- ui/src/ui/views/agents-utils.test.ts | 10 + ui/src/ui/views/chat.test.ts | 258 ++++++++---------------- ui/src/ui/views/chat.ts | 166 ++------------- 13 files changed, 622 insertions(+), 632 deletions(-) create mode 100644 ui/src/ui/chat/chat-avatar.test.ts create mode 100644 ui/src/ui/chat/chat-avatar.ts create mode 100644 ui/src/ui/chat/chat-queue.ts create mode 100644 ui/src/ui/chat/chat-sidebar-raw.ts create mode 100644 ui/src/ui/chat/chat-welcome.ts diff --git a/ui/src/ui/chat/chat-avatar.test.ts b/ui/src/ui/chat/chat-avatar.test.ts new file mode 100644 index 00000000000..44b354da58b --- /dev/null +++ b/ui/src/ui/chat/chat-avatar.test.ts @@ -0,0 +1,101 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { renderChatAvatar } from "./chat-avatar.ts"; + +vi.mock("../views/agents-utils.ts", () => { + const isRenderableControlUiAvatarUrl = (value: string) => + /^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//")); + + return { + assistantAvatarFallbackUrl: () => "/openclaw-molty.png", + isRenderableControlUiAvatarUrl, + resolveAssistantTextAvatar: (value: string | null | undefined) => { + const trimmed = value?.trim(); + if (!trimmed || trimmed === "A") { + return null; + } + if (trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed)) { + return null; + } + if ( + trimmed.length > 8 || + /\s/.test(trimmed) || + /[\\/.:]/.test(trimmed) || + /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u.test(trimmed) + ) { + return null; + } + return trimmed; + }, + resolveChatAvatarRenderUrl: ( + candidate: string | null | undefined, + agent: { identity?: { avatar?: string; avatarUrl?: string } }, + ) => { + if (typeof candidate === "string" && candidate.startsWith("blob:")) { + return candidate; + } + for (const value of [candidate, agent.identity?.avatarUrl, agent.identity?.avatar]) { + if (typeof value === "string" && isRenderableControlUiAvatarUrl(value)) { + return value; + } + } + return null; + }, + }; +}); + +function renderAvatar(params: Parameters) { + const container = document.createElement("div"); + render(renderChatAvatar(...params), container); + return container.querySelector(".chat-avatar"); +} + +describe("renderChatAvatar", () => { + it("uses the assistant fallback when no assistant avatar is configured", () => { + const avatar = renderAvatar(["assistant"]); + + expect(avatar).not.toBeNull(); + expect(avatar?.getAttribute("src")).toBe("/openclaw-molty.png"); + }); + + it("renders assistant fallback, blob image, and text avatars", () => { + const remoteAvatar = renderAvatar([ + "assistant", + { avatar: "https://example.com/avatar.png", name: "Val" }, + ]); + expect(remoteAvatar?.getAttribute("src")).toBe("/openclaw-molty.png"); + + const blobAvatar = renderAvatar(["assistant", { avatar: "blob:managed-image", name: "Val" }]); + expect(blobAvatar?.tagName).toBe("IMG"); + expect(blobAvatar?.getAttribute("src")).toBe("blob:managed-image"); + + const textAvatar = renderAvatar(["assistant", { avatar: "VC", name: "Val" }]); + expect(textAvatar?.tagName).toBe("DIV"); + expect(textAvatar?.textContent).toContain("VC"); + expect(textAvatar?.getAttribute("aria-label")).toBe("Val"); + }); + + it("uses the assistant fallback while authenticated avatar routes are loading", () => { + const avatar = renderAvatar([ + "assistant", + { avatar: "/avatar/main", name: "OpenClaw" }, + undefined, + "", + "session-token", + ]); + + expect(avatar?.getAttribute("src")).toBe("/openclaw-molty.png"); + }); + + it("renders local user image and text avatars", () => { + const imageAvatar = renderAvatar(["user", undefined, { name: "Buns", avatar: "/avatar/user" }]); + expect(imageAvatar?.getAttribute("src")).toBe("/avatar/user"); + expect(imageAvatar?.getAttribute("alt")).toBe("Buns"); + + const textAvatar = renderAvatar(["user", undefined, { name: "Buns", avatar: "AB" }]); + expect(textAvatar?.tagName).toBe("DIV"); + expect(textAvatar?.textContent).toContain("AB"); + }); +}); diff --git a/ui/src/ui/chat/chat-avatar.ts b/ui/src/ui/chat/chat-avatar.ts new file mode 100644 index 00000000000..756a04eae5a --- /dev/null +++ b/ui/src/ui/chat/chat-avatar.ts @@ -0,0 +1,127 @@ +import { html } from "lit"; +import type { AssistantIdentity } from "../assistant-identity.ts"; +import { + resolveLocalUserAvatarText, + resolveLocalUserAvatarUrl, + resolveLocalUserName, +} from "../user-identity.ts"; +import { + assistantAvatarFallbackUrl, + isRenderableControlUiAvatarUrl, + resolveAssistantTextAvatar, +} from "../views/agents-utils.ts"; +import { normalizeRoleForGrouping } from "./message-normalizer.ts"; + +export function renderChatAvatar( + role: string, + assistant?: Pick, + user?: { name?: string | null; avatar?: string | null }, + basePath?: string, + authToken?: string | null, +) { + const normalized = normalizeRoleForGrouping(role); + const assistantName = assistant?.name?.trim() || "Assistant"; + const assistantAvatar = assistant?.avatar?.trim() || ""; + const assistantAvatarText = resolveAssistantTextAvatar(assistantAvatar); + const assistantFallbackAvatar = assistantAvatarFallbackUrl(basePath ?? ""); + const userName = resolveLocalUserName(user); + const userAvatarUrl = resolveLocalUserAvatarUrl(user); + const userAvatarText = resolveLocalUserAvatarText(user); + const initial = + normalized === "user" + ? html` + + + + + ` + : normalized === "assistant" + ? html` + + + + ` + : normalized === "tool" + ? html` + + + + ` + : html` + + + + ? + + + `; + const className = + normalized === "user" + ? "user" + : normalized === "assistant" + ? "assistant" + : normalized === "tool" + ? "tool" + : "other"; + + if (normalized === "user" && userAvatarUrl) { + return html`${userName}`; + } + + if (normalized === "user" && userAvatarText) { + return html`
+ ${userAvatarText} +
`; + } + + if (assistantAvatar && normalized === "assistant") { + if (isAvatarUrl(assistantAvatar)) { + if (authToken?.trim() && assistantAvatar.startsWith("/")) { + return html``; + } + return html`${assistantName}`; + } + if (assistantAvatarText) { + return html`
+ ${assistantAvatarText} +
`; + } + return html``; + } + + if (normalized === "assistant") { + return html``; + } + + return html`
${initial}
`; +} + +function isAvatarUrl(value: string): boolean { + const trimmed = value.trim(); + return trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed); +} diff --git a/ui/src/ui/chat/chat-queue.ts b/ui/src/ui/chat/chat-queue.ts new file mode 100644 index 00000000000..ed4fb7c8dc2 --- /dev/null +++ b/ui/src/ui/chat/chat-queue.ts @@ -0,0 +1,67 @@ +import { html, nothing } from "lit"; +import { icons } from "../icons.ts"; +import type { ChatQueueItem } from "../ui-types.ts"; + +export type ChatQueueProps = { + queue: ChatQueueItem[]; + canAbort?: boolean; + onQueueSteer?: (id: string) => void; + onQueueRemove: (id: string) => void; +}; + +export function renderChatQueue(props: ChatQueueProps) { + if (!props.queue.length) { + return nothing; + } + return html` +
+
Queued (${props.queue.length})
+
+ ${props.queue.map( + (item) => html` +
+
+ ${item.kind === "steered" + ? html`Steered` + : nothing} +
+ ${item.text || + (item.attachments?.length ? `Image (${item.attachments.length})` : "")} +
+
+
+ ${props.canAbort && + props.onQueueSteer && + item.kind !== "steered" && + !item.localCommandName + ? html` + + ` + : nothing} + +
+
+ `, + )} +
+
+ `; +} diff --git a/ui/src/ui/chat/chat-sidebar-raw.ts b/ui/src/ui/chat/chat-sidebar-raw.ts new file mode 100644 index 00000000000..df1d253a575 --- /dev/null +++ b/ui/src/ui/chat/chat-sidebar-raw.ts @@ -0,0 +1,29 @@ +import type { SidebarContent } from "../sidebar-content.ts"; + +function toPlainTextCodeFence(value: string, language = ""): string { + const fenceHeader = language ? `\`\`\`${language}` : "```"; + return `${fenceHeader}\n${value}\n\`\`\``; +} + +export function buildRawSidebarContent( + content: SidebarContent | null | undefined, +): SidebarContent | null { + if (!content) { + return null; + } + if (content.kind === "markdown") { + const rawText = content.rawText ?? content.content; + return { + kind: "markdown", + content: toPlainTextCodeFence(rawText), + rawText, + }; + } + if (content.rawText?.trim()) { + return { + kind: "markdown", + content: toPlainTextCodeFence(content.rawText, "json"), + }; + } + return null; +} diff --git a/ui/src/ui/chat/chat-welcome.ts b/ui/src/ui/chat/chat-welcome.ts new file mode 100644 index 00000000000..4edb3c8d01f --- /dev/null +++ b/ui/src/ui/chat/chat-welcome.ts @@ -0,0 +1,88 @@ +import { html } from "lit"; +import { + agentLogoUrl, + assistantAvatarFallbackUrl, + resolveChatAvatarRenderUrl, + resolveAssistantTextAvatar, +} from "../views/agents-utils.ts"; + +export type ChatWelcomeProps = { + assistantName: string; + assistantAvatar: string | null; + assistantAvatarUrl?: string | null; + basePath?: string; + onDraftChange: (next: string) => void; + onSend: () => void; +}; + +const WELCOME_SUGGESTIONS = [ + "What can you do?", + "Summarize my recent sessions", + "Help me configure a channel", + "Check system health", +]; + +function resolveAssistantAvatarUrl( + props: Pick, +): string | null { + return resolveChatAvatarRenderUrl(props.assistantAvatarUrl, { + identity: { + avatar: props.assistantAvatar ?? undefined, + avatarUrl: props.assistantAvatarUrl ?? undefined, + }, + }); +} + +export function resolveAssistantDisplayAvatar( + props: Pick, +): string | null { + return resolveAssistantAvatarUrl(props) ?? resolveAssistantTextAvatar(props.assistantAvatar); +} + +export function renderWelcomeState(props: ChatWelcomeProps) { + const name = props.assistantName || "Assistant"; + const avatar = resolveAssistantAvatarUrl(props); + const avatarText = avatar ? null : resolveAssistantTextAvatar(props.assistantAvatar); + const fallbackAvatarUrl = assistantAvatarFallbackUrl(props.basePath ?? ""); + const logoUrl = agentLogoUrl(props.basePath ?? ""); + + return html` +
+
+ ${avatar + ? html`${name}` + : avatarText + ? html`
+ ${avatarText} +
` + : html``} +

${name}

+
+ Ready to chat +
+

Type a message below · / for commands

+
+ ${WELCOME_SUGGESTIONS.map( + (text) => html` + + `, + )} +
+
+ `; +} diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index 5d2f90b2ac5..994cd139948 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -2,17 +2,25 @@ import { html, render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { getSafeLocalStorage } from "../../local-storage.ts"; import type { MessageGroup } from "../types/chat-types.ts"; import { formatChatTimestampForDisplay, renderMessageGroup, renderStreamingGroup, - resolveAssistantTextAvatar, resetAssistantAttachmentAvailabilityCacheForTest, } from "./grouped-render.ts"; import { normalizeMessage } from "./message-normalizer.ts"; +const localStorageValues = vi.hoisted(() => new Map()); + +vi.mock("../../local-storage.ts", () => ({ + getSafeLocalStorage: () => ({ + getItem: (key: string) => localStorageValues.get(key) ?? null, + removeItem: (key: string) => localStorageValues.delete(key), + setItem: (key: string, value: string) => localStorageValues.set(key, value), + }), +})); + vi.mock("../markdown.ts", () => ({ toSanitizedMarkdownHtml: (value: string) => value, })); @@ -69,6 +77,14 @@ vi.mock("../views/agents-utils.ts", () => { }; }); +vi.mock("./chat-avatar.ts", () => ({ + renderChatAvatar: (role: string) => { + const element = document.createElement("div"); + element.className = `chat-avatar ${role}`; + return element; + }, +})); + vi.mock("../tool-display.ts", () => ({ formatToolDetail: () => undefined, resolveToolDisplay: ({ name }: { name: string }) => ({ @@ -202,11 +218,7 @@ function renderMessageGroups( } function clearDeleteConfirmSkip() { - try { - getSafeLocalStorage()?.removeItem("openclaw:skipDeleteConfirm"); - } catch { - /* noop */ - } + localStorageValues.delete("openclaw:skipDeleteConfirm"); } async function flushAssistantAttachmentAvailabilityChecks() { @@ -221,38 +233,6 @@ afterEach(() => { }); describe("grouped chat rendering", () => { - it("falls back to the logo while authenticated avatar routes are loading", () => { - const container = document.createElement("div"); - renderAssistantMessage( - container, - { - role: "assistant", - content: [{ type: "text", text: "Hello" }], - }, - { - assistantAvatar: "/avatar/main", - assistantAttachmentAuthToken: "session-token", - }, - ); - - const img = container.querySelector("img.chat-avatar"); - expect(img?.getAttribute("src")).toBe("/openclaw-molty.png"); - }); - - it("uses the Molty png as the default assistant transcript avatar", () => { - const container = document.createElement("div"); - - renderAssistantMessage(container, { - role: "assistant", - content: "hello", - timestamp: 1000, - }); - - const avatar = container.querySelector(".chat-avatar.assistant"); - expect(avatar).not.toBeNull(); - expect(avatar?.getAttribute("src")).toBe("/openclaw-molty.png"); - }); - it("positions delete confirm by message side", () => { const renderDeletable = (role: "user" | "assistant") => { const container = document.createElement("div"); @@ -297,41 +277,6 @@ describe("grouped chat rendering", () => { expect(assistantConfirm?.classList.contains("chat-delete-confirm--right")).toBe(true); }); - it("renders assistant avatar variants", () => { - const renderAvatar = (assistantAvatar: string) => { - const container = document.createElement("div"); - renderAssistantMessage( - container, - { - role: "assistant", - content: "hello", - timestamp: 1000, - }, - { assistantAvatar, assistantName: "Val" }, - ); - return container.querySelector(".chat-avatar.assistant"); - }; - - const remoteAvatar = renderAvatar("https://example.com/avatar.png"); - expect(remoteAvatar?.getAttribute("src")).toBe("/openclaw-molty.png"); - - const blobAvatar = renderAvatar("blob:managed-image"); - expect(blobAvatar?.tagName).toBe("IMG"); - expect(blobAvatar?.getAttribute("src")).toBe("blob:managed-image"); - - const textAvatar = renderAvatar("VC"); - expect(textAvatar?.tagName).toBe("DIV"); - expect(textAvatar?.textContent).toContain("VC"); - expect(textAvatar?.getAttribute("aria-label")).toBe("Val"); - }); - - it("rejects unsafe invisible controls in assistant text avatars", () => { - expect(resolveAssistantTextAvatar("VC")).toBe("VC"); - expect(resolveAssistantTextAvatar("\u{1F43E}")).toBe("\u{1F43E}"); - expect(resolveAssistantTextAvatar("V\u202eC")).toBeNull(); - expect(resolveAssistantTextAvatar("V\u200bC")).toBeNull(); - }); - it("renders assistant context usage from input and cache tokens", () => { const renderUsage = (usage: Record, contextWindow: number) => { const container = document.createElement("div"); @@ -378,7 +323,7 @@ describe("grouped chat rendering", () => { expect(outputHeavy.querySelector(".msg-meta__ctx")?.textContent).toBe("10% ctx"); }); - it("renders full dates with message timestamps", () => { + it("renders full dates with message and streaming timestamps", () => { const container = document.createElement("div"); const timestamp = Date.UTC(2026, 3, 24, 18, 30); @@ -394,19 +339,14 @@ describe("grouped chat rendering", () => { expect(time?.dateTime).toBe(display.dateTime); expect(time?.textContent?.trim()).toBe(display.label); expect(time?.getAttribute("title")).toBe(display.title); - }); - - it("renders full dates with streaming timestamps", () => { - const container = document.createElement("div"); - const timestamp = Date.UTC(2026, 3, 24, 18, 30); render(renderStreamingGroup("Working", timestamp), container); - const time = container.querySelector(".chat-group-timestamp"); - expect(time?.textContent?.trim()).toBe(formatChatTimestampForDisplay(timestamp).label); + const streamingTime = container.querySelector(".chat-group-timestamp"); + expect(streamingTime?.textContent?.trim()).toBe(display.label); }); - it("renders configured local user names and avatar variants", () => { + it("renders configured local user names", () => { const renderUser = (opts: Partial) => { const container = document.createElement("div"); renderGroupedMessage( @@ -426,18 +366,8 @@ describe("grouped chat rendering", () => { const sender = named.querySelector(".chat-group.user .chat-sender-name"); expect(sender?.textContent).toBe("Buns"); - for (const src of ["data:image/png;base64,AAA", "/avatar/user"]) { - const container = renderUser({ userName: "Buns", userAvatar: src }); - const avatar = container.querySelector(".chat-avatar.user"); - expect(avatar?.getAttribute("src")).toBe(src); - expect(avatar?.getAttribute("alt")).toBe("Buns"); - } - - const textAvatar = renderUser({ userAvatar: "🦞" }).querySelector( - ".chat-avatar.user", - ); - expect(textAvatar?.tagName).toBe("DIV"); - expect(textAvatar?.textContent).toContain("🦞"); + const avatar = named.querySelector(".chat-avatar.user"); + expect(avatar?.tagName).toBe("DIV"); }); it("keeps inline tool cards collapsed by default and renders expanded state", () => { @@ -643,7 +573,7 @@ describe("grouped chat rendering", () => { expect(container.textContent).not.toContain("MEDIA:https://example.com/photo.png"); }); - it("renders allowed transcript images and skips blocked/non-image media", () => { + it("renders allowed transcript and content image variants", () => { const renderUserMedia = (message: unknown) => { const container = document.createElement("div"); renderGroupedMessage(container, message, "user", { @@ -699,6 +629,22 @@ describe("grouped chat rendering", () => { "/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Fsecond.jpg&token=session-token", ]); + const assistantContainer = document.createElement("div"); + renderAssistantMessage( + assistantContainer, + { + role: "assistant", + content: [{ type: "input_image", image_url: "data:image/png;base64,cG5n" }], + timestamp: Date.now(), + }, + { showToolCalls: false }, + ); + expect( + assistantContainer + .querySelector(".chat-message-image") + ?.getAttribute("src"), + ).toBe("data:image/png;base64,cG5n"); + container = renderUserMedia({ id: "user-history-image-blocked", role: "user", @@ -721,23 +667,6 @@ describe("grouped chat rendering", () => { expect(container.querySelector(".chat-message-image")).toBeNull(); }); - it("renders legacy input_image image_url blocks", () => { - const container = document.createElement("div"); - - renderAssistantMessage( - container, - { - role: "assistant", - content: [{ type: "input_image", image_url: "data:image/png;base64,cG5n" }], - timestamp: Date.now(), - }, - { showToolCalls: false }, - ); - - const image = container.querySelector(".chat-message-image"); - expect(image?.getAttribute("src")).toBe("data:image/png;base64,cG5n"); - }); - it("fetches managed chat images with auth and renders blob previews", async () => { resetAssistantAttachmentAvailabilityCacheForTest(); const objectUrl = "blob:managed-image"; diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index f51f22ed9d9..463069fb944 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -15,17 +15,9 @@ import type { NormalizedMessage, ToolCard, } from "../types/chat-types.ts"; -import { - resolveLocalUserAvatarText, - resolveLocalUserAvatarUrl, - resolveLocalUserName, -} from "../user-identity.ts"; -import { - assistantAvatarFallbackUrl, - isRenderableControlUiAvatarUrl, - resolveAssistantTextAvatar, -} from "../views/agents-utils.ts"; +import { resolveLocalUserName } from "../user-identity.ts"; export { resolveAssistantTextAvatar } from "../views/agents-utils.ts"; +import { renderChatAvatar } from "./chat-avatar.ts"; import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts"; import { extractTextCached, @@ -270,7 +262,7 @@ export function renderReadingIndicatorGroup( ) { return html`
- ${renderAvatar("assistant", assistant, undefined, basePath, authToken)} + ${renderChatAvatar("assistant", assistant, undefined, basePath, authToken)}