perf: split chat UI test dependencies

This commit is contained in:
Peter Steinberger
2026-04-25 14:06:12 +01:00
parent 3db60f7eab
commit 2413c0f5a5
13 changed files with 622 additions and 632 deletions

View File

@@ -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<typeof renderChatAvatar>) {
const container = document.createElement("div");
render(renderChatAvatar(...params), container);
return container.querySelector<HTMLElement>(".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");
});
});

View File

@@ -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<AssistantIdentity, "name" | "avatar">,
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`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<circle cx="12" cy="8" r="4" />
<path d="M20 21a8 8 0 1 0-16 0" />
</svg>
`
: normalized === "assistant"
? html`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<path d="M12 2l2.4 7.2H22l-6 4.8 2.4 7.2L12 16l-6.4 5.2L8 14 2 9.2h7.6z" />
</svg>
`
: normalized === "tool"
? html`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<path
d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53a7.76 7.76 0 0 0 .07-1 7.76 7.76 0 0 0-.07-.97l2.11-1.63a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.15 7.15 0 0 0-1.69-.98l-.38-2.65A.49.49 0 0 0 14 2h-4a.49.49 0 0 0-.49.42l-.38 2.65a7.15 7.15 0 0 0-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.49.49 0 0 0 .12.64L4.57 11a7.9 7.9 0 0 0 0 1.94l-2.11 1.69a.49.49 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.72 1.69.98l.38 2.65c.05.24.26.42.49.42h4c.23 0 .44-.18.49-.42l.38-2.65a7.15 7.15 0 0 0 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.49.49 0 0 0-.12-.64z"
/>
</svg>
`
: html`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<circle cx="12" cy="12" r="10" />
<text
x="12"
y="16.5"
text-anchor="middle"
font-size="14"
font-weight="600"
fill="var(--bg, #fff)"
>
?
</text>
</svg>
`;
const className =
normalized === "user"
? "user"
: normalized === "assistant"
? "assistant"
: normalized === "tool"
? "tool"
: "other";
if (normalized === "user" && userAvatarUrl) {
return html`<img class="chat-avatar ${className}" src="${userAvatarUrl}" alt="${userName}" />`;
}
if (normalized === "user" && userAvatarText) {
return html`<div class="chat-avatar ${className}" aria-label="${userName}">
${userAvatarText}
</div>`;
}
if (assistantAvatar && normalized === "assistant") {
if (isAvatarUrl(assistantAvatar)) {
if (authToken?.trim() && assistantAvatar.startsWith("/")) {
return html`<img
class="chat-avatar ${className} chat-avatar--logo"
src="${assistantFallbackAvatar}"
alt="${assistantName}"
/>`;
}
return html`<img
class="chat-avatar ${className}"
src="${assistantAvatar}"
alt="${assistantName}"
/>`;
}
if (assistantAvatarText) {
return html`<div class="chat-avatar ${className}" aria-label="${assistantName}">
${assistantAvatarText}
</div>`;
}
return html`<img
class="chat-avatar ${className} chat-avatar--logo"
src="${assistantFallbackAvatar}"
alt="${assistantName}"
/>`;
}
if (normalized === "assistant") {
return html`<img
class="chat-avatar ${className} chat-avatar--logo"
src="${assistantFallbackAvatar}"
alt="${assistantName}"
/>`;
}
return html`<div class="chat-avatar ${className}">${initial}</div>`;
}
function isAvatarUrl(value: string): boolean {
const trimmed = value.trim();
return trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed);
}

View File

@@ -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`
<div class="chat-queue" role="status" aria-live="polite">
<div class="chat-queue__title">Queued (${props.queue.length})</div>
<div class="chat-queue__list">
${props.queue.map(
(item) => html`
<div
class="chat-queue__item ${item.kind === "steered" ? "chat-queue__item--steered" : ""}"
>
<div class="chat-queue__main">
${item.kind === "steered"
? html`<span class="chat-queue__badge">Steered</span>`
: nothing}
<div class="chat-queue__text">
${item.text ||
(item.attachments?.length ? `Image (${item.attachments.length})` : "")}
</div>
</div>
<div class="chat-queue__actions">
${props.canAbort &&
props.onQueueSteer &&
item.kind !== "steered" &&
!item.localCommandName
? html`
<button
class="btn chat-queue__steer"
type="button"
title="Steer now"
aria-label="Steer queued message"
@click=${() => props.onQueueSteer?.(item.id)}
>
${icons.cornerDownRight}
<span>Steer</span>
</button>
`
: nothing}
<button
class="btn chat-queue__remove"
type="button"
aria-label="Remove queued message"
@click=${() => props.onQueueRemove(item.id)}
>
${icons.x}
</button>
</div>
</div>
`,
)}
</div>
</div>
`;
}

View File

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

View File

@@ -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<ChatWelcomeProps, "assistantAvatar" | "assistantAvatarUrl">,
): string | null {
return resolveChatAvatarRenderUrl(props.assistantAvatarUrl, {
identity: {
avatar: props.assistantAvatar ?? undefined,
avatarUrl: props.assistantAvatarUrl ?? undefined,
},
});
}
export function resolveAssistantDisplayAvatar(
props: Pick<ChatWelcomeProps, "assistantAvatar" | "assistantAvatarUrl">,
): 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`
<div class="agent-chat__welcome" style="--agent-color: var(--accent)">
<div class="agent-chat__welcome-glow"></div>
${avatar
? html`<img
src=${avatar}
alt=${name}
style="width:56px; height:56px; border-radius:50%; object-fit:cover;"
/>`
: avatarText
? html`<div class="agent-chat__avatar agent-chat__avatar--text" aria-label=${name}>
${avatarText}
</div>`
: html`<div class="agent-chat__avatar agent-chat__avatar--logo">
<img src=${fallbackAvatarUrl} alt=${name} />
</div>`}
<h2>${name}</h2>
<div class="agent-chat__badges">
<span class="agent-chat__badge"><img src=${logoUrl} alt="" /> Ready to chat</span>
</div>
<p class="agent-chat__hint">Type a message below &middot; <kbd>/</kbd> for commands</p>
<div class="agent-chat__suggestions">
${WELCOME_SUGGESTIONS.map(
(text) => html`
<button
type="button"
class="agent-chat__suggestion"
@click=${() => {
props.onDraftChange(text);
props.onSend();
}}
>
${text}
</button>
`,
)}
</div>
</div>
`;
}

View File

@@ -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<string, string>());
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<HTMLImageElement>(".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<HTMLElement>(".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<string, number>, 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<HTMLTimeElement>(".chat-group-timestamp");
expect(time?.textContent?.trim()).toBe(formatChatTimestampForDisplay(timestamp).label);
const streamingTime = container.querySelector<HTMLTimeElement>(".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<RenderMessageGroupOptions>) => {
const container = document.createElement("div");
renderGroupedMessage(
@@ -426,18 +366,8 @@ describe("grouped chat rendering", () => {
const sender = named.querySelector<HTMLElement>(".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<HTMLImageElement>(".chat-avatar.user");
expect(avatar?.getAttribute("src")).toBe(src);
expect(avatar?.getAttribute("alt")).toBe("Buns");
}
const textAvatar = renderUser({ userAvatar: "🦞" }).querySelector<HTMLElement>(
".chat-avatar.user",
);
expect(textAvatar?.tagName).toBe("DIV");
expect(textAvatar?.textContent).toContain("🦞");
const avatar = named.querySelector<HTMLElement>(".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<HTMLImageElement>(".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<HTMLImageElement>(".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";

View File

@@ -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`
<div class="chat-group assistant">
${renderAvatar("assistant", assistant, undefined, basePath, authToken)}
${renderChatAvatar("assistant", assistant, undefined, basePath, authToken)}
<div class="chat-group-messages">
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
<span class="chat-reading-indicator__dots">
@@ -294,7 +286,7 @@ export function renderStreamingGroup(
return html`
<div class="chat-group assistant">
${renderAvatar("assistant", assistant, undefined, basePath, authToken)}
${renderChatAvatar("assistant", assistant, undefined, basePath, authToken)}
<div class="chat-group-messages">
${renderGroupedMessage(
{
@@ -370,7 +362,7 @@ export function renderMessageGroup(
return html`
<div class="chat-group ${roleClass}">
${renderAvatar(
${renderChatAvatar(
group.role,
{
name: assistantName,
@@ -679,120 +671,6 @@ function renderTtsButton(group: MessageGroup) {
`;
}
function renderAvatar(
role: string,
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
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`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<circle cx="12" cy="8" r="4" />
<path d="M20 21a8 8 0 1 0-16 0" />
</svg>
`
: normalized === "assistant"
? html`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<path d="M12 2l2.4 7.2H22l-6 4.8 2.4 7.2L12 16l-6.4 5.2L8 14 2 9.2h7.6z" />
</svg>
`
: normalized === "tool"
? html`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<path
d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53a7.76 7.76 0 0 0 .07-1 7.76 7.76 0 0 0-.07-.97l2.11-1.63a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.15 7.15 0 0 0-1.69-.98l-.38-2.65A.49.49 0 0 0 14 2h-4a.49.49 0 0 0-.49.42l-.38 2.65a7.15 7.15 0 0 0-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.49.49 0 0 0 .12.64L4.57 11a7.9 7.9 0 0 0 0 1.94l-2.11 1.69a.49.49 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.72 1.69.98l.38 2.65c.05.24.26.42.49.42h4c.23 0 .44-.18.49-.42l.38-2.65a7.15 7.15 0 0 0 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.49.49 0 0 0-.12-.64z"
/>
</svg>
`
: html`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<circle cx="12" cy="12" r="10" />
<text
x="12"
y="16.5"
text-anchor="middle"
font-size="14"
font-weight="600"
fill="var(--bg, #fff)"
>
?
</text>
</svg>
`;
const className =
normalized === "user"
? "user"
: normalized === "assistant"
? "assistant"
: normalized === "tool"
? "tool"
: "other";
if (normalized === "user" && userAvatarUrl) {
return html`<img class="chat-avatar ${className}" src="${userAvatarUrl}" alt="${userName}" />`;
}
if (normalized === "user" && userAvatarText) {
return html`<div class="chat-avatar ${className}" aria-label="${userName}">
${userAvatarText}
</div>`;
}
if (assistantAvatar && normalized === "assistant") {
if (isAvatarUrl(assistantAvatar)) {
if (authToken?.trim() && assistantAvatar.startsWith("/")) {
return html`<img
class="chat-avatar ${className} chat-avatar--logo"
src="${assistantFallbackAvatar}"
alt="${assistantName}"
/>`;
}
return html`<img
class="chat-avatar ${className}"
src="${assistantAvatar}"
alt="${assistantName}"
/>`;
}
if (assistantAvatarText) {
return html`<div class="chat-avatar ${className}" aria-label="${assistantName}">
${assistantAvatarText}
</div>`;
}
return html`<img
class="chat-avatar ${className} chat-avatar--logo"
src="${assistantFallbackAvatar}"
alt="${assistantName}"
/>`;
}
if (normalized === "assistant") {
return html`<img
class="chat-avatar ${className} chat-avatar--logo"
src="${assistantFallbackAvatar}"
alt="${assistantName}"
/>`;
}
return html`<div class="chat-avatar ${className}">${initial}</div>`;
}
function isAvatarUrl(value: string): boolean {
const trimmed = value.trim();
return trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed);
}
function resolveRenderableMessageImages(
images: ImageBlock[],
opts?: ImageRenderOptions,

View File

@@ -1,7 +1,7 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AppViewState } from "../app-view-state.ts";
import {
createModelCatalog,
@@ -146,18 +146,16 @@ function createChatHeaderState(
return { state, request };
}
function flushTasks() {
async function flushTasks() {
return new Promise<void>((resolve) => queueMicrotask(resolve));
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("chat session controls", () => {
it("patches the current session model from the chat header picker", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
} satisfies Partial<Response>),
);
const { state, request } = createChatHeaderState();
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
@@ -170,25 +168,22 @@ describe("chat session controls", () => {
modelSelect!.value = "openai/gpt-5-mini";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await flushTasks();
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "main",
model: "openai/gpt-5-mini",
});
expect(request).not.toHaveBeenCalledWith("chat.history", expect.anything());
expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini");
expect(state.sessionsResult?.sessions[0]?.modelProvider).toBe("openai");
vi.unstubAllGlobals();
await vi.waitFor(
() => {
expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini");
expect(state.sessionsResult?.sessions[0]?.modelProvider).toBe("openai");
},
{ interval: 1, timeout: 100 },
);
});
it("reloads effective tools after a chat-header model switch for the active tools panel", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
} satisfies Partial<Response>),
);
const { state, request } = createChatHeaderState();
state.agentsPanel = "tools";
state.agentsSelectedId = "main";
@@ -217,18 +212,10 @@ describe("chat session controls", () => {
},
{ interval: 1, timeout: 100 },
);
expect(state.toolsEffectiveResultKey).toBe("main:main:model=openai/gpt-5-mini");
vi.unstubAllGlobals();
});
it("clears the session model override back to the default model", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
} satisfies Partial<Response>),
);
const { state, request } = createChatHeaderState({ model: "gpt-5-mini" });
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
@@ -241,14 +228,17 @@ describe("chat session controls", () => {
modelSelect!.value = "";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await flushTasks();
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "main",
model: null,
});
expect(state.sessionsResult?.sessions[0]?.model).toBeUndefined();
vi.unstubAllGlobals();
await vi.waitFor(
() => {
expect(state.sessionsResult?.sessions[0]?.model).toBeUndefined();
},
{ interval: 1, timeout: 100 },
);
});
it("disables the chat header model picker while a run is active", () => {
@@ -266,12 +256,6 @@ describe("chat session controls", () => {
});
it("keeps the selected model visible when the active session is absent from sessions.list", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
} satisfies Partial<Response>),
);
const { state } = createChatHeaderState({ omitSessionFromList: true });
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
@@ -290,7 +274,6 @@ describe("chat session controls", () => {
'select[data-chat-model-select="true"]',
);
expect(rerendered?.value).toBe("openai/gpt-5-mini");
vi.unstubAllGlobals();
});
it("uses default thinking options when the active session is absent", () => {

View File

@@ -6,7 +6,6 @@ import {
resolveChatModelOverrideValue,
resolveChatModelSelectState,
} from "../chat-model-select-state.ts";
import { loadSessions } from "../controllers/sessions.ts";
import { pushUniqueTrimmedSelectOption } from "../select-options.ts";
import { parseAgentSessionKey } from "../session-key.ts";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts";
@@ -71,6 +70,7 @@ export function renderChatSessionSelect(
}
async function refreshSessionOptions(state: AppViewState) {
const { loadSessions } = await import("../controllers/sessions.ts");
await loadSessions(state as unknown as Parameters<typeof loadSessions>[0], {
activeMinutes: 0,
limit: 0,

View File

@@ -13,6 +13,18 @@ vi.mock("../icons.ts", () => ({
),
}));
vi.mock("../tool-display.ts", () => ({
formatToolDetail: () => undefined,
resolveToolDisplay: ({ name }: { name: string }) => ({
name,
label: name
.split(/[._-]/g)
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
.join(" "),
icon: "zap",
}),
}));
describe("tool-cards", () => {
it("pretty-prints structured args and pairs tool output onto the same card", () => {
const cards = extractToolCards(
@@ -187,10 +199,10 @@ describe("tool-cards", () => {
});
});
it("drops tool_card-targeted canvas payloads", () => {
const [card] = extractToolCards(
it("does not create previews for non-assistant canvas or generic outputs", () => {
const cases = [
{
role: "tool",
name: "tool-card target",
toolName: "canvas_render",
content: JSON.stringify({
kind: "canvas",
@@ -205,16 +217,8 @@ describe("tool-cards", () => {
},
}),
},
"msg:view:2",
);
expect(card?.preview).toBeUndefined();
});
it("does not extract inline-html canvas payloads into canvas previews", () => {
const [card] = extractToolCards(
{
role: "tool",
name: "inline html",
toolName: "canvas_render",
content: JSON.stringify({
kind: "canvas",
@@ -229,36 +233,30 @@ describe("tool-cards", () => {
},
}),
},
"msg:view:3",
);
expect(card?.preview).toBeUndefined();
});
it("does not create a view preview for malformed json output", () => {
const [card] = extractToolCards(
{
role: "tool",
name: "malformed json",
toolName: "canvas_render",
content: '{"kind":"present_view","view":{"id":"broken"}',
},
"msg:view:4",
);
expect(card?.preview).toBeUndefined();
});
it("does not create a view preview for generic tool text output", () => {
const [card] = extractToolCards(
{
role: "tool",
name: "generic text",
toolName: "browser.open",
content: "present_view: cv_widget",
},
"msg:view:5",
);
] as const;
expect(card?.preview).toBeUndefined();
for (const testCase of cases) {
const [card] = extractToolCards(
{
role: "tool",
toolName: testCase.toolName,
content: testCase.content,
},
`msg:view:${testCase.name}`,
);
expect(card?.preview, testCase.name).toBeUndefined();
}
});
it("renders expanded cards with inline input and output sections", () => {

View File

@@ -5,6 +5,7 @@ import {
buildAgentContext,
resolveConfiguredCronModelSuggestions,
resolveAgentAvatarUrl,
resolveAssistantTextAvatar,
resolveChatAvatarRenderUrl,
resolveEffectiveModelFallbacks,
sortLocaleStrings,
@@ -122,6 +123,15 @@ describe("assistantAvatarFallbackUrl", () => {
});
});
describe("resolveAssistantTextAvatar", () => {
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();
});
});
describe("resolveAgentAvatarUrl", () => {
it("prefers a runtime avatar URL over non-URL identity avatars", () => {
expect(

View File

@@ -1,13 +1,11 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { renderChatQueue } from "../chat/chat-queue.ts";
import { buildRawSidebarContent } from "../chat/chat-sidebar-raw.ts";
import { renderWelcomeState } from "../chat/chat-welcome.ts";
import type { ChatQueueItem } from "../ui-types.ts";
import { cleanupChatModuleState, renderChat, type ChatProps } from "./chat.ts";
vi.mock("../markdown.ts", () => ({
toSanitizedMarkdownHtml: (value: string) => value,
}));
vi.mock("../icons.ts", () => ({
icons: new Proxy(
@@ -18,85 +16,61 @@ vi.mock("../icons.ts", () => ({
),
}));
vi.mock("../tool-display.ts", () => ({
formatToolDetail: () => undefined,
resolveToolDisplay: ({ name }: { name: string }) => ({
name,
label: name,
icon: "zap",
}),
vi.mock("./agents-utils.ts", () => ({
agentLogoUrl: () => "/openclaw-logo.svg",
assistantAvatarFallbackUrl: () => "apple-touch-icon.png",
resolveChatAvatarRenderUrl: (
candidate: string | null | undefined,
agent: { identity?: { avatar?: string; avatarUrl?: string } },
) => {
if (typeof candidate === "string" && candidate.startsWith("blob:")) {
return candidate;
}
if (
typeof agent.identity?.avatarUrl === "string" &&
agent.identity.avatarUrl.startsWith("blob:")
) {
return agent.identity.avatarUrl;
}
return null;
},
resolveAssistantTextAvatar: (value: string | null | undefined) => {
if (!value) {
return null;
}
return value.length <= 3 ? value : null;
},
}));
function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
return {
sessionKey: "agent:main:main",
onSessionKeyChange: () => undefined,
thinkingLevel: null,
showThinking: true,
showToolCalls: true,
loading: false,
sending: false,
canAbort: true,
messages: [],
toolMessages: [],
streamSegments: [],
stream: "Working...",
streamStartedAt: 1,
draft: "",
queue: [],
connected: true,
canSend: true,
disabledReason: null,
error: null,
sessions: {
ts: 0,
path: "",
count: 1,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [{ key: "agent:main:main", kind: "direct", status: "running", updatedAt: null }],
},
focusMode: false,
assistantName: "Test Agent",
assistantAvatar: null,
onRefresh: () => undefined,
onToggleFocusMode: () => undefined,
onDraftChange: () => undefined,
onSend: () => undefined,
onAbort: () => undefined,
onQueueRemove: () => undefined,
onNewSession: () => undefined,
agentsList: { agents: [{ id: "main", name: "Main" }], defaultId: "main" },
currentAgentId: "main",
onAgentChange: () => undefined,
...overrides,
};
}
function renderQueue(queue: ChatQueueItem[], onQueueSteer = vi.fn()) {
function renderQueue(params: {
queue: ChatQueueItem[];
canAbort?: boolean;
onQueueSteer?: (id: string) => void;
}) {
const container = document.createElement("div");
render(
renderChat(
createProps({
queue,
onQueueSteer,
}),
),
renderChatQueue({
queue: params.queue,
canAbort: params.canAbort ?? true,
onQueueSteer: params.onQueueSteer,
onQueueRemove: () => undefined,
}),
container,
);
return { container, onQueueSteer };
return container;
}
describe("chat view queue steering", () => {
afterEach(() => {
cleanupChatModuleState();
});
describe("chat queue", () => {
it("renders Steer only for queued messages during an active run", () => {
const { container, onQueueSteer } = renderQueue([
{ id: "queued-1", text: "tighten the plan", createdAt: 1 },
{ id: "steered-1", text: "already sent", createdAt: 2, kind: "steered" },
{ id: "local-1", text: "/status", createdAt: 3, localCommandName: "status" },
]);
const onQueueSteer = vi.fn();
const container = renderQueue({
onQueueSteer,
queue: [
{ id: "queued-1", text: "tighten the plan", createdAt: 1 },
{ id: "steered-1", text: "already sent", createdAt: 2, kind: "steered" },
{ id: "local-1", text: "/status", createdAt: 3, localCommandName: "status" },
],
});
const steerButtons = container.querySelectorAll<HTMLButtonElement>(".chat-queue__steer");
expect(steerButtons).toHaveLength(1);
@@ -109,131 +83,77 @@ describe("chat view queue steering", () => {
});
it("hides queued-message Steer when no run is active", () => {
const { container } = renderQueue(
[{ id: "queued-1", text: "tighten the plan", createdAt: 1 }],
vi.fn(),
);
render(
renderChat(
createProps({
canAbort: false,
stream: null,
queue: [{ id: "queued-1", text: "tighten the plan", createdAt: 1 }],
}),
),
container,
);
const container = renderQueue({
canAbort: false,
onQueueSteer: vi.fn(),
queue: [{ id: "queued-1", text: "tighten the plan", createdAt: 1 }],
});
expect(container.querySelector(".chat-queue__steer")).toBeNull();
});
});
describe("renderChat", () => {
afterEach(() => {
cleanupChatModuleState();
});
describe("chat sidebar raw content", () => {
it("keeps markdown raw text toggles idempotent", () => {
const container = document.createElement("div");
const onOpenSidebar = vi.fn();
const rawMarkdown = "```ts\nconst value = 1;\n```";
render(
renderChat(
createProps({
sidebarOpen: true,
sidebarContent: {
kind: "markdown",
content: `\`\`\`\n${rawMarkdown}\n\`\`\``,
rawText: rawMarkdown,
},
stream: null,
streamStartedAt: null,
onCloseSidebar: () => undefined,
onOpenSidebar,
}),
),
container,
);
const rawButton = Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
(button) => button.textContent?.includes("View Raw Text"),
);
rawButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(rawButton).not.toBeNull();
expect(onOpenSidebar).toHaveBeenCalledWith({
expect(
buildRawSidebarContent({
kind: "markdown",
content: `\`\`\`\n${rawMarkdown}\n\`\`\``,
rawText: rawMarkdown,
}),
).toEqual({
kind: "markdown",
content: `\`\`\`\n${rawMarkdown}\n\`\`\``,
rawText: rawMarkdown,
});
});
});
it("renders configured assistant text avatars in transcript groups", () => {
describe("chat welcome", () => {
function renderWelcome(params: {
assistantAvatar: string | null;
assistantAvatarUrl?: string | null;
}) {
const container = document.createElement("div");
render(
renderChat(
createProps({
assistantName: "Val",
assistantAvatar: "VC",
assistantAvatarUrl: null,
messages: [{ role: "assistant", content: "hello", timestamp: 1000 }],
stream: null,
streamStartedAt: null,
}),
),
renderWelcomeState({
assistantName: "Val",
assistantAvatar: params.assistantAvatar,
assistantAvatarUrl: params.assistantAvatarUrl,
onDraftChange: () => undefined,
onSend: () => undefined,
}),
container,
);
return container;
}
const avatar = container.querySelector<HTMLElement>(".chat-group.assistant .chat-avatar");
it("renders configured assistant text avatars in the welcome state", () => {
const container = renderWelcome({ assistantAvatar: "VC", assistantAvatarUrl: null });
const avatar = container.querySelector<HTMLElement>(".agent-chat__avatar");
expect(avatar).not.toBeNull();
expect(avatar?.tagName).toBe("DIV");
expect(avatar?.textContent).toContain("VC");
expect(avatar?.getAttribute("aria-label")).toBe("Val");
});
it("renders configured assistant image avatars in transcript groups", () => {
const container = document.createElement("div");
it("renders configured assistant image avatars in the welcome state", () => {
const container = renderWelcome({
assistantAvatar: "avatars/val.png",
assistantAvatarUrl: "blob:identity-avatar",
});
render(
renderChat(
createProps({
assistantName: "Val",
assistantAvatar: "avatars/val.png",
assistantAvatarUrl: "blob:identity-avatar",
messages: [{ role: "assistant", content: "hello", timestamp: 1000 }],
stream: null,
streamStartedAt: null,
}),
),
container,
);
const avatar = container.querySelector<HTMLImageElement>(
".chat-group.assistant img.chat-avatar",
);
const avatar = container.querySelector<HTMLImageElement>("img");
expect(avatar).not.toBeNull();
expect(avatar?.getAttribute("src")).toBe("blob:identity-avatar");
expect(avatar?.getAttribute("alt")).toBe("Val");
});
it("uses the Molty png as the welcome fallback assistant avatar", () => {
const container = document.createElement("div");
render(
renderChat(
createProps({
assistantName: "Val",
assistantAvatar: null,
assistantAvatarUrl: null,
messages: [],
stream: null,
streamStartedAt: null,
}),
),
container,
);
const container = renderWelcome({ assistantAvatar: null, assistantAvatarUrl: null });
const avatar = container.querySelector<HTMLImageElement>(".agent-chat__avatar--logo img");
expect(avatar).not.toBeNull();

View File

@@ -7,6 +7,9 @@ import {
isSupportedChatAttachmentMimeType,
} from "../chat/attachment-support.ts";
import { buildChatItems } from "../chat/build-chat-items.ts";
import { renderChatQueue } from "../chat/chat-queue.ts";
import { buildRawSidebarContent } from "../chat/chat-sidebar-raw.ts";
import { renderWelcomeState, resolveAssistantDisplayAvatar } from "../chat/chat-welcome.ts";
import { renderContextNotice } from "../chat/context-notice.ts";
import { DeletedMessages } from "../chat/deleted-messages.ts";
import { exportChatMarkdown } from "../chat/export.ts";
@@ -14,7 +17,6 @@ import {
renderMessageGroup,
renderReadingIndicatorGroup,
renderStreamingGroup,
resolveAssistantTextAvatar,
} from "../chat/grouped-render.ts";
import { InputHistory } from "../chat/input-history.ts";
import { PinnedMessages } from "../chat/pinned-messages.ts";
@@ -34,7 +36,6 @@ import {
} from "../chat/slash-commands.ts";
import { isSttSupported, startStt, stopStt } from "../chat/speech.ts";
import { renderCompactionIndicator, renderFallbackIndicator } from "../chat/status-indicators.ts";
import { buildSidebarContent } from "../chat/tool-cards.ts";
import { getExpandedToolCards, syncToolCardExpansionState } from "../chat/tool-expansion-state.ts";
import type { EmbedSandboxMode } from "../embed-sandbox.ts";
import { icons } from "../icons.ts";
@@ -43,11 +44,6 @@ import { detectTextDirection } from "../text-direction.ts";
import type { SessionsListResult } from "../types.ts";
import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts";
import { resolveLocalUserName } from "../user-identity.ts";
import {
agentLogoUrl,
assistantAvatarFallbackUrl,
resolveChatAvatarRenderUrl,
} from "./agents-utils.ts";
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
import "../components/resizable-divider.ts";
@@ -145,11 +141,6 @@ function getPinnedMessages(sessionKey: string): PinnedMessages {
);
}
function toPlainTextCodeFence(value: string, language = ""): string {
const fenceHeader = language ? `\`\`\`${language}` : "```";
return `${fenceHeader}\n${value}\n\`\`\``;
}
function getDeletedMessages(sessionKey: string): DeletedMessages {
return getOrCreateSessionCacheValue(
deletedMessagesMap,
@@ -477,78 +468,6 @@ function exportMarkdown(props: ChatProps): void {
exportChatMarkdown(props.messages, props.assistantName);
}
const WELCOME_SUGGESTIONS = [
"What can you do?",
"Summarize my recent sessions",
"Help me configure a channel",
"Check system health",
];
function renderWelcomeState(props: ChatProps): TemplateResult {
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`
<div class="agent-chat__welcome" style="--agent-color: var(--accent)">
<div class="agent-chat__welcome-glow"></div>
${avatar
? html`<img
src=${avatar}
alt=${name}
style="width:56px; height:56px; border-radius:50%; object-fit:cover;"
/>`
: avatarText
? html`<div class="agent-chat__avatar agent-chat__avatar--text" aria-label=${name}>
${avatarText}
</div>`
: html`<div class="agent-chat__avatar agent-chat__avatar--logo">
<img src=${fallbackAvatarUrl} alt=${name} />
</div>`}
<h2>${name}</h2>
<div class="agent-chat__badges">
<span class="agent-chat__badge"><img src=${logoUrl} alt="" /> Ready to chat</span>
</div>
<p class="agent-chat__hint">Type a message below &middot; <kbd>/</kbd> for commands</p>
<div class="agent-chat__suggestions">
${WELCOME_SUGGESTIONS.map(
(text) => html`
<button
type="button"
class="agent-chat__suggestion"
@click=${() => {
props.onDraftChange(text);
props.onSend();
}}
>
${text}
</button>
`,
)}
</div>
</div>
`;
}
function resolveAssistantAvatarUrl(
props: Pick<ChatProps, "assistantAvatar" | "assistantAvatarUrl">,
): string | null {
return resolveChatAvatarRenderUrl(props.assistantAvatarUrl, {
identity: {
avatar: props.assistantAvatar ?? undefined,
avatarUrl: props.assistantAvatarUrl ?? undefined,
},
});
}
function resolveAssistantDisplayAvatar(
props: Pick<ChatProps, "assistantAvatar" | "assistantAvatarUrl">,
): string | null {
return resolveAssistantAvatarUrl(props) ?? resolveAssistantTextAvatar(props.assistantAvatar);
}
function renderSearchBar(requestUpdate: () => void): TemplateResult | typeof nothing {
if (!vs.searchOpen) {
return nothing;
@@ -1135,22 +1054,12 @@ export function renderChat(props: ChatProps) {
allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false,
onClose: props.onCloseSidebar!,
onViewRawText: () => {
if (!props.sidebarContent || !props.onOpenSidebar) {
if (!props.onOpenSidebar) {
return;
}
if (props.sidebarContent.kind === "markdown") {
const rawText = props.sidebarContent.rawText ?? props.sidebarContent.content;
props.onOpenSidebar(
buildSidebarContent(toPlainTextCodeFence(rawText), { rawText }),
);
return;
}
if (props.sidebarContent.rawText?.trim()) {
props.onOpenSidebar(
buildSidebarContent(
toPlainTextCodeFence(props.sidebarContent.rawText, "json"),
),
);
const rawContent = buildRawSidebarContent(props.sidebarContent);
if (rawContent) {
props.onOpenSidebar(rawContent);
}
},
})}
@@ -1159,61 +1068,12 @@ export function renderChat(props: ChatProps) {
: nothing}
</div>
${props.queue.length
? html`
<div class="chat-queue" role="status" aria-live="polite">
<div class="chat-queue__title">Queued (${props.queue.length})</div>
<div class="chat-queue__list">
${props.queue.map(
(item) => html`
<div
class="chat-queue__item ${item.kind === "steered"
? "chat-queue__item--steered"
: ""}"
>
<div class="chat-queue__main">
${item.kind === "steered"
? html`<span class="chat-queue__badge">Steered</span>`
: nothing}
<div class="chat-queue__text">
${item.text ||
(item.attachments?.length ? `Image (${item.attachments.length})` : "")}
</div>
</div>
<div class="chat-queue__actions">
${props.canAbort &&
props.onQueueSteer &&
item.kind !== "steered" &&
!item.localCommandName
? html`
<button
class="btn chat-queue__steer"
type="button"
title="Steer now"
aria-label="Steer queued message"
@click=${() => props.onQueueSteer?.(item.id)}
>
${icons.cornerDownRight}
<span>Steer</span>
</button>
`
: nothing}
<button
class="btn chat-queue__remove"
type="button"
aria-label="Remove queued message"
@click=${() => props.onQueueRemove(item.id)}
>
${icons.x}
</button>
</div>
</div>
`,
)}
</div>
</div>
`
: nothing}
${renderChatQueue({
queue: props.queue,
canAbort: props.canAbort,
onQueueSteer: props.onQueueSteer,
onQueueRemove: props.onQueueRemove,
})}
${renderSideResult(props.sideResult, props.onDismissSideResult)}
${renderFallbackIndicator(props.fallbackStatus)}
${renderCompactionIndicator(props.compactionStatus)}