test(ui): consolidate chat jsdom suites

This commit is contained in:
Peter Steinberger
2026-04-25 20:17:15 +01:00
parent 7436e395d5
commit d757396785
8 changed files with 839 additions and 910 deletions

View File

@@ -1,101 +0,0 @@
/* @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

@@ -1,160 +0,0 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewaySessionRow } from "../types.ts";
vi.mock("../markdown.ts", () => ({
toSanitizedMarkdownHtml: (value: string) => value,
}));
vi.mock("../icons.ts", () => ({
icons: {},
}));
import {
getContextNoticeViewModel,
renderContextNotice,
resetContextNoticeThemeCacheForTest,
} from "./context-notice.ts";
import { renderSideResult } from "./side-result-render.ts";
describe("context notice", () => {
afterEach(() => {
vi.restoreAllMocks();
resetContextNoticeThemeCacheForTest();
});
it("renders only for fresh high current usage", () => {
const container = document.createElement("div");
vi.spyOn(window, "getComputedStyle").mockReturnValue({
getPropertyValue: (name: string) =>
name === "--warn" ? "#010203" : name === "--danger" ? "#040506" : "",
} as CSSStyleDeclaration);
resetContextNoticeThemeCacheForTest();
expect(
getContextNoticeViewModel(
{
key: "main",
kind: "direct",
updatedAt: null,
inputTokens: 757_300,
totalTokens: 46_000,
contextTokens: 200_000,
},
200_000,
),
).toBeNull();
const session: GatewaySessionRow = {
key: "main",
kind: "direct",
updatedAt: null,
inputTokens: 757_300,
totalTokens: 190_000,
contextTokens: 200_000,
};
render(renderContextNotice(session, 200_000), container);
expect(container.textContent).toContain("95% context used");
expect(container.textContent).toContain("190k / 200k");
expect(getContextNoticeViewModel(session, 200_000)?.compactRecommended).toBe(true);
expect(container.textContent).not.toContain("757.3k / 200k");
const notice = container.querySelector<HTMLElement>(".context-notice");
expect(notice).not.toBeNull();
expect(notice?.style.getPropertyValue("--ctx-color")).toContain("rgb(");
expect(notice?.style.getPropertyValue("--ctx-color")).toContain("4, 5, 6");
expect(notice?.style.getPropertyValue("--ctx-color")).not.toContain("NaN");
expect(notice?.style.getPropertyValue("--ctx-bg")).not.toContain("NaN");
const icon = container.querySelector<SVGElement>(".context-notice__icon");
expect(icon).not.toBeNull();
expect(icon?.tagName.toLowerCase()).toBe("svg");
expect(icon?.classList.contains("context-notice__icon")).toBe(true);
expect(icon?.getAttribute("width")).toBe("16");
expect(icon?.getAttribute("height")).toBe("16");
expect(icon?.querySelector("path")).not.toBeNull();
const onCompact = vi.fn();
render(renderContextNotice(session, 200_000, { onCompact }), container);
expect(container.textContent).toContain("Compact");
container.querySelector<HTMLButtonElement>(".context-notice__action")?.click();
expect(onCompact).toHaveBeenCalledTimes(1);
expect(
getContextNoticeViewModel(
{
key: "main",
kind: "direct",
updatedAt: null,
inputTokens: 500_000,
contextTokens: 200_000,
},
200_000,
),
).toBeNull();
expect(
getContextNoticeViewModel(
{
key: "main",
kind: "direct",
updatedAt: null,
totalTokens: 190_000,
totalTokensFresh: false,
contextTokens: 200_000,
},
200_000,
),
).toBeNull();
});
});
describe("side result render", () => {
it("renders, dismisses, and styles BTW side results outside transcript history", () => {
const container = document.createElement("div");
const onDismissSideResult = vi.fn();
render(
renderSideResult(
{
kind: "btw",
runId: "btw-run-1",
sessionKey: "main",
question: "what changed?",
text: "The web UI now renders **BTW** separately.",
isError: false,
ts: 2,
},
onDismissSideResult,
),
container,
);
expect(container.querySelector(".chat-side-result")).not.toBeNull();
expect(container.textContent).toContain("BTW");
expect(container.textContent).toContain("what changed?");
expect(container.textContent).toContain("Not saved to chat history");
expect(container.querySelectorAll(".chat-side-result")).toHaveLength(1);
const button = container.querySelector<HTMLButtonElement>(".chat-side-result__dismiss");
expect(button).not.toBeNull();
button?.click();
expect(onDismissSideResult).toHaveBeenCalledTimes(1);
render(
renderSideResult({
kind: "btw",
runId: "btw-run-3",
sessionKey: "main",
question: "what failed?",
text: "The side question could not be answered.",
isError: true,
ts: 4,
}),
container,
);
expect(container.querySelector(".chat-side-result--error")).not.toBeNull();
});
});

View File

@@ -263,43 +263,50 @@ afterEach(() => {
describe("grouped chat rendering", () => {
it("positions delete confirm by message side", () => {
const renderDeletable = (role: "user" | "assistant") => {
const container = document.createElement("div");
clearDeleteConfirmSkip();
renderGroupedMessage(
container,
{
role,
content: `hello from ${role}`,
timestamp: 1000,
},
role,
{ onDelete: vi.fn() },
);
return container;
};
const container = document.createElement("div");
clearDeleteConfirmSkip();
renderMessageGroups(
container,
[
createMessageGroup(
{
role: "user",
content: "hello from user",
timestamp: 1000,
},
"user",
),
createMessageGroup(
{
role: "assistant",
content: "hello from assistant",
timestamp: 1001,
},
"assistant",
),
],
{ onDelete: vi.fn() },
);
const userContainer = renderDeletable("user");
const userDeleteButton = userContainer.querySelector<HTMLButtonElement>(
const userDeleteButton = container.querySelector<HTMLButtonElement>(
".chat-group.user .chat-group-delete",
);
expect(userDeleteButton).not.toBeNull();
userDeleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const userConfirm = userContainer.querySelector<HTMLElement>(
const userConfirm = container.querySelector<HTMLElement>(
".chat-group.user .chat-delete-confirm",
);
expect(userConfirm).not.toBeNull();
expect(userConfirm?.classList.contains("chat-delete-confirm--left")).toBe(true);
const assistantContainer = renderDeletable("assistant");
const assistantDeleteButton = assistantContainer.querySelector<HTMLButtonElement>(
const assistantDeleteButton = container.querySelector<HTMLButtonElement>(
".chat-group.assistant .chat-group-delete",
);
expect(assistantDeleteButton).not.toBeNull();
assistantDeleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const assistantConfirm = assistantContainer.querySelector<HTMLElement>(
const assistantConfirm = container.querySelector<HTMLElement>(
".chat-group.assistant .chat-delete-confirm",
);
expect(assistantConfirm).not.toBeNull();

View File

@@ -1,13 +1,38 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { html, render } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewaySessionRow } from "../types.ts";
import {
getContextNoticeViewModel,
renderContextNotice,
resetContextNoticeThemeCacheForTest,
} from "./context-notice.ts";
import { renderChatRunControls, type ChatRunControlsProps } from "./run-controls.ts";
import { renderSideResult } from "./side-result-render.ts";
import { renderCompactionIndicator, renderFallbackIndicator } from "./status-indicators.ts";
import { renderToolCard } from "./tool-cards.ts";
vi.mock("../icons.ts", () => ({
icons: {},
}));
vi.mock("../markdown.ts", () => ({
toSanitizedMarkdownHtml: (value: string) => value,
}));
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",
}),
}));
function createProps(overrides: Partial<ChatRunControlsProps> = {}): ChatRunControlsProps {
return {
canAbort: false,
@@ -130,3 +155,395 @@ describe("chat run controls", () => {
expect(onAbort).toHaveBeenCalledTimes(1);
});
});
describe("chat status indicators", () => {
it("renders compaction and fallback indicators while they are fresh", () => {
const container = document.createElement("div");
const nowSpy = vi.spyOn(Date, "now");
const renderIndicators = (
compactionStatus: Parameters<typeof renderCompactionIndicator>[0],
fallbackStatus: Parameters<typeof renderFallbackIndicator>[0],
) => {
render(
html`${renderFallbackIndicator(fallbackStatus)}
${renderCompactionIndicator(compactionStatus)}`,
container,
);
};
try {
nowSpy.mockReturnValue(1_000);
renderIndicators(
{
phase: "active",
runId: "run-1",
startedAt: 1_000,
completedAt: null,
},
{
selected: "fireworks/minimax-m2p5",
active: "deepinfra/moonshotai/Kimi-K2.5",
attempts: ["fireworks/minimax-m2p5: rate limit"],
occurredAt: 900,
},
);
let indicator = container.querySelector(".compaction-indicator--active");
expect(indicator).not.toBeNull();
expect(indicator?.textContent).toContain("Compacting context...");
indicator = container.querySelector(".compaction-indicator--fallback");
expect(indicator).not.toBeNull();
expect(indicator?.textContent).toContain("Fallback active: deepinfra/moonshotai/Kimi-K2.5");
renderIndicators(
{
phase: "complete",
runId: "run-1",
startedAt: 900,
completedAt: 900,
},
{
phase: "cleared",
selected: "fireworks/minimax-m2p5",
active: "fireworks/minimax-m2p5",
previous: "deepinfra/moonshotai/Kimi-K2.5",
attempts: [],
occurredAt: 900,
},
);
indicator = container.querySelector(".compaction-indicator--complete");
expect(indicator).not.toBeNull();
expect(indicator?.textContent).toContain("Context compacted");
indicator = container.querySelector(".compaction-indicator--fallback-cleared");
expect(indicator).not.toBeNull();
expect(indicator?.textContent).toContain("Fallback cleared: fireworks/minimax-m2p5");
nowSpy.mockReturnValue(20_000);
renderIndicators(
{
phase: "complete",
runId: "run-1",
startedAt: 0,
completedAt: 0,
},
{
selected: "fireworks/minimax-m2p5",
active: "deepinfra/moonshotai/Kimi-K2.5",
attempts: [],
occurredAt: 0,
},
);
expect(container.querySelector(".compaction-indicator--fallback")).toBeNull();
expect(container.querySelector(".compaction-indicator--complete")).toBeNull();
} finally {
nowSpy.mockRestore();
}
});
});
describe("context notice", () => {
afterEach(() => {
vi.restoreAllMocks();
resetContextNoticeThemeCacheForTest();
});
it("renders only for fresh high current usage", () => {
const container = document.createElement("div");
vi.spyOn(window, "getComputedStyle").mockReturnValue({
getPropertyValue: (name: string) =>
name === "--warn" ? "#010203" : name === "--danger" ? "#040506" : "",
} as CSSStyleDeclaration);
resetContextNoticeThemeCacheForTest();
expect(
getContextNoticeViewModel(
{
key: "main",
kind: "direct",
updatedAt: null,
inputTokens: 757_300,
totalTokens: 46_000,
contextTokens: 200_000,
},
200_000,
),
).toBeNull();
const session: GatewaySessionRow = {
key: "main",
kind: "direct",
updatedAt: null,
inputTokens: 757_300,
totalTokens: 190_000,
contextTokens: 200_000,
};
render(renderContextNotice(session, 200_000), container);
expect(container.textContent).toContain("95% context used");
expect(container.textContent).toContain("190k / 200k");
expect(getContextNoticeViewModel(session, 200_000)?.compactRecommended).toBe(true);
expect(container.textContent).not.toContain("757.3k / 200k");
const notice = container.querySelector<HTMLElement>(".context-notice");
expect(notice).not.toBeNull();
expect(notice?.style.getPropertyValue("--ctx-color")).toContain("rgb(");
expect(notice?.style.getPropertyValue("--ctx-color")).toContain("4, 5, 6");
expect(notice?.style.getPropertyValue("--ctx-color")).not.toContain("NaN");
expect(notice?.style.getPropertyValue("--ctx-bg")).not.toContain("NaN");
const icon = container.querySelector<SVGElement>(".context-notice__icon");
expect(icon).not.toBeNull();
expect(icon?.tagName.toLowerCase()).toBe("svg");
expect(icon?.classList.contains("context-notice__icon")).toBe(true);
expect(icon?.getAttribute("width")).toBe("16");
expect(icon?.getAttribute("height")).toBe("16");
expect(icon?.querySelector("path")).not.toBeNull();
const onCompact = vi.fn();
render(renderContextNotice(session, 200_000, { onCompact }), container);
expect(container.textContent).toContain("Compact");
container.querySelector<HTMLButtonElement>(".context-notice__action")?.click();
expect(onCompact).toHaveBeenCalledTimes(1);
expect(
getContextNoticeViewModel(
{
key: "main",
kind: "direct",
updatedAt: null,
inputTokens: 500_000,
contextTokens: 200_000,
},
200_000,
),
).toBeNull();
expect(
getContextNoticeViewModel(
{
key: "main",
kind: "direct",
updatedAt: null,
totalTokens: 190_000,
totalTokensFresh: false,
contextTokens: 200_000,
},
200_000,
),
).toBeNull();
});
});
describe("side result render", () => {
it("renders, dismisses, and styles BTW side results outside transcript history", () => {
const container = document.createElement("div");
const onDismissSideResult = vi.fn();
render(
renderSideResult(
{
kind: "btw",
runId: "btw-run-1",
sessionKey: "main",
question: "what changed?",
text: "The web UI now renders **BTW** separately.",
isError: false,
ts: 2,
},
onDismissSideResult,
),
container,
);
expect(container.querySelector(".chat-side-result")).not.toBeNull();
expect(container.textContent).toContain("BTW");
expect(container.textContent).toContain("what changed?");
expect(container.textContent).toContain("Not saved to chat history");
expect(container.querySelectorAll(".chat-side-result")).toHaveLength(1);
const button = container.querySelector<HTMLButtonElement>(".chat-side-result__dismiss");
expect(button).not.toBeNull();
button?.click();
expect(onDismissSideResult).toHaveBeenCalledTimes(1);
render(
renderSideResult({
kind: "btw",
runId: "btw-run-3",
sessionKey: "main",
question: "what failed?",
text: "The side question could not be answered.",
isError: true,
ts: 4,
}),
container,
);
expect(container.querySelector(".chat-side-result--error")).not.toBeNull();
});
});
describe("tool-cards", () => {
it("renders expanded cards with inline input and output sections", () => {
const container = document.createElement("div");
const toggle = vi.fn();
render(
renderToolCard(
{
id: "msg:4:call-4",
name: "browser.open",
args: { url: "https://example.com" },
inputText: '{\n "url": "https://example.com"\n}',
outputText: "Opened page",
},
{ expanded: true, onToggleExpanded: toggle },
),
container,
);
expect(container.textContent).toContain("Tool input");
expect(container.textContent).toContain("Tool output");
expect(container.textContent).toContain("https://example.com");
expect(container.textContent).toContain("Opened page");
});
it("renders expanded tool calls without an inline output block when no output is present", () => {
const container = document.createElement("div");
render(
renderToolCard(
{
id: "msg:4b:call-4b",
name: "sessions_spawn",
args: { mode: "session", thread: true },
inputText: '{\n "mode": "session",\n "thread": true\n}',
},
{ expanded: true, onToggleExpanded: vi.fn() },
),
container,
);
expect(container.textContent).toContain("Tool input");
expect(container.textContent).toContain('"thread": true');
expect(container.textContent).not.toContain("Tool output");
expect(container.textContent).not.toContain("No output");
});
it("labels collapsed tool calls as tool call", () => {
const container = document.createElement("div");
render(
renderToolCard(
{
id: "msg:5:call-5",
name: "sessions_spawn",
args: { mode: "run" },
inputText: '{\n "mode": "run"\n}',
},
{ expanded: false, onToggleExpanded: vi.fn() },
),
container,
);
expect(container.textContent).toContain("Tool call");
expect(container.textContent).not.toContain("Tool input");
const summaryButton = container.querySelector("button.chat-tool-msg-summary");
expect(summaryButton).not.toBeNull();
expect(summaryButton?.getAttribute("aria-expanded")).toBe("false");
});
it("keeps raw details for legacy canvas tool output without rendering tool-row previews", () => {
const container = document.createElement("div");
render(
renderToolCard(
{
id: "msg:view:7",
name: "canvas_render",
outputText: JSON.stringify({
kind: "canvas",
view: {
backend: "canvas",
id: "cv_counter",
url: "/__openclaw__/canvas/documents/cv_counter/index.html",
title: "Counter demo",
preferred_height: 480,
},
presentation: {
target: "tool_card",
},
}),
preview: {
kind: "canvas",
surface: "assistant_message",
render: "url",
viewId: "cv_counter",
title: "Counter demo",
url: "/__openclaw__/canvas/documents/cv_counter/index.html",
preferredHeight: 480,
},
},
{ expanded: true, onToggleExpanded: vi.fn() },
),
container,
);
const rawToggle = container.querySelector<HTMLButtonElement>(".chat-tool-card__raw-toggle");
const rawBody = container.querySelector<HTMLElement>(".chat-tool-card__raw-body");
expect(container.textContent).toContain("Counter demo");
expect(container.querySelector(".chat-tool-card__preview-frame")).toBeNull();
expect(rawToggle?.getAttribute("aria-expanded")).toBe("false");
expect(rawBody?.hidden).toBe(true);
rawToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(rawToggle?.getAttribute("aria-expanded")).toBe("true");
expect(rawBody?.hidden).toBe(false);
expect(rawBody?.textContent).toContain('"kind":"canvas"');
});
it("opens assistant-surface canvas payloads in the sidebar when explicitly requested", () => {
const container = document.createElement("div");
const onOpenSidebar = vi.fn();
render(
renderToolCard(
{
id: "msg:view:8",
name: "canvas_render",
outputText: JSON.stringify({
kind: "canvas",
view: {
backend: "canvas",
id: "cv_sidebar",
url: "/__openclaw__/canvas/documents/cv_sidebar/index.html",
title: "Player",
preferred_height: 360,
},
presentation: {
target: "assistant_message",
},
}),
preview: {
kind: "canvas",
surface: "assistant_message",
render: "url",
viewId: "cv_sidebar",
url: "/__openclaw__/canvas/documents/cv_sidebar/index.html",
title: "Player",
preferredHeight: 360,
},
},
{ expanded: true, onToggleExpanded: vi.fn(), onOpenSidebar },
),
container,
);
const sidebarButton = container.querySelector<HTMLButtonElement>(".chat-tool-card__action-btn");
sidebarButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(sidebarButton).not.toBeNull();
expect(onOpenSidebar).toHaveBeenCalledWith(
expect.objectContaining({
kind: "canvas",
docId: "cv_sidebar",
entryUrl: "/__openclaw__/canvas/documents/cv_sidebar/index.html",
}),
);
});
});

View File

@@ -1,318 +0,0 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AppViewState } from "../app-view-state.ts";
import {
createModelCatalog,
createSessionsListResult,
DEFAULT_CHAT_MODEL_CATALOG,
} from "../chat-model.test-helpers.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import type { ModelCatalogEntry } from "../types.ts";
import { renderChatSessionSelect } from "./session-controls.ts";
const refreshVisibleToolsEffectiveForCurrentSessionMock = vi.hoisted(() =>
vi.fn(async (state: AppViewState) => {
const agentId = state.agentsSelectedId ?? "main";
const sessionKey = state.sessionKey;
await state.client?.request("tools.effective", { agentId, sessionKey });
const override = state.chatModelOverrides[sessionKey];
state.toolsEffectiveResultKey = `${agentId}:${sessionKey}:model=${override?.value ?? "(default)"}`;
state.toolsEffectiveResult = { agentId, profile: "coding", groups: [] };
}),
);
const loadSessionsMock = vi.hoisted(() =>
vi.fn(async (state: AppViewState) => {
const res = await state.client?.request("sessions.list", {
includeGlobal: true,
includeUnknown: true,
});
if (res) {
state.sessionsResult = res as AppViewState["sessionsResult"];
}
}),
);
vi.mock("../controllers/agents.ts", () => ({
refreshVisibleToolsEffectiveForCurrentSession: refreshVisibleToolsEffectiveForCurrentSessionMock,
}));
vi.mock("../controllers/sessions.ts", () => ({
loadSessions: loadSessionsMock,
}));
function createChatHeaderState(
overrides: {
model?: string | null;
modelProvider?: string | null;
models?: ModelCatalogEntry[];
omitSessionFromList?: boolean;
} = {},
): { state: AppViewState; request: ReturnType<typeof vi.fn> } {
let currentModel = overrides.model ?? null;
let currentModelProvider = overrides.modelProvider ?? (currentModel ? "openai" : null);
const omitSessionFromList = overrides.omitSessionFromList ?? false;
const catalog = overrides.models ?? createModelCatalog(...DEFAULT_CHAT_MODEL_CATALOG);
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
if (method === "sessions.patch") {
const nextModel = (params.model as string | null | undefined) ?? null;
if (!nextModel) {
currentModel = null;
currentModelProvider = null;
} else {
const normalized = nextModel.trim();
const slashIndex = normalized.indexOf("/");
if (slashIndex > 0) {
currentModelProvider = normalized.slice(0, slashIndex);
currentModel = normalized.slice(slashIndex + 1);
} else {
currentModel = normalized;
const matchingProviders = catalog
.filter((entry) => entry.id === normalized)
.map((entry) => entry.provider)
.filter(Boolean);
currentModelProvider =
matchingProviders.length === 1 ? matchingProviders[0] : currentModelProvider;
}
}
return { ok: true, key: "main" };
}
if (method === "chat.history") {
return { messages: [], thinkingLevel: null };
}
if (method === "sessions.list") {
return createSessionsListResult({
model: currentModel,
modelProvider: currentModelProvider,
omitSessionFromList,
});
}
if (method === "models.list") {
return { models: catalog };
}
if (method === "tools.effective") {
return {
agentId: "main",
profile: "coding",
groups: [],
};
}
throw new Error(`Unexpected request: ${method}`);
});
const state = {
sessionKey: "main",
connected: true,
sessionsHideCron: true,
sessionsResult: createSessionsListResult({
model: currentModel,
modelProvider: currentModelProvider,
omitSessionFromList,
}),
chatModelOverrides: {},
chatModelCatalog: catalog,
chatModelsLoading: false,
client: { request } as unknown as GatewayBrowserClient,
settings: {
gatewayUrl: "",
token: "",
locale: "en",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "dark",
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
borderRadius: 50,
chatFocusMode: false,
chatShowThinking: false,
},
chatMessage: "",
chatStream: null,
chatStreamStartedAt: null,
chatRunId: null,
chatQueue: [],
chatMessages: [],
chatLoading: false,
chatThinkingLevel: null,
lastError: null,
chatAvatarUrl: null,
basePath: "",
hello: null,
agentsList: null,
agentsPanel: "overview",
agentsSelectedId: null,
toolsEffectiveLoading: false,
toolsEffectiveLoadingKey: null,
toolsEffectiveResultKey: null,
toolsEffectiveError: null,
toolsEffectiveResult: null,
applySettings(next: AppViewState["settings"]) {
state.settings = next;
},
loadAssistantIdentity: vi.fn(),
resetToolStream: vi.fn(),
resetChatScroll: vi.fn(),
} as unknown as AppViewState & {
client: GatewayBrowserClient;
settings: AppViewState["settings"];
};
return { state, request };
}
async function flushTasks(turns = 8) {
for (let i = 0; i < turns; i += 1) {
await Promise.resolve();
}
await vi.dynamicImportSettled();
}
afterEach(() => {
loadSessionsMock.mockClear();
refreshVisibleToolsEffectiveForCurrentSessionMock.mockClear();
vi.unstubAllGlobals();
});
describe("chat session controls", () => {
it("patches the current session model from the chat header picker", async () => {
const { state, request } = createChatHeaderState();
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
expect(modelSelect?.value).toBe("");
modelSelect!.value = "openai/gpt-5-mini";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "main",
model: "openai/gpt-5-mini",
});
expect(request).not.toHaveBeenCalledWith("chat.history", expect.anything());
await flushTasks();
expect(loadSessionsMock).toHaveBeenCalledTimes(1);
expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini");
expect(state.sessionsResult?.sessions[0]?.modelProvider).toBe("openai");
});
it("reloads effective tools after a chat-header model switch for the active tools panel", async () => {
const { state, request } = createChatHeaderState();
state.agentsPanel = "tools";
state.agentsSelectedId = "main";
state.toolsEffectiveResultKey = "main:main";
state.toolsEffectiveResult = {
agentId: "main",
profile: "coding",
groups: [],
};
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
modelSelect!.value = "openai/gpt-5-mini";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await flushTasks();
expect(request).toHaveBeenCalledWith("tools.effective", {
agentId: "main",
sessionKey: "main",
});
expect(state.toolsEffectiveResultKey).toBe("main:main:model=openai/gpt-5-mini");
});
it("clears the session model override back to the default model", async () => {
const { state, request } = createChatHeaderState({ model: "gpt-5-mini" });
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
expect(modelSelect?.value).toBe("openai/gpt-5-mini");
modelSelect!.value = "";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "main",
model: null,
});
await flushTasks();
expect(loadSessionsMock).toHaveBeenCalledTimes(1);
expect(state.sessionsResult?.sessions[0]?.model).toBeUndefined();
});
it("disables the chat header model picker while a run is active", () => {
const { state } = createChatHeaderState();
state.chatRunId = "run-123";
state.chatStream = "Working";
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
expect(modelSelect?.disabled).toBe(true);
});
it("keeps the selected model visible when the active session is absent from sessions.list", async () => {
const { state } = createChatHeaderState({ omitSessionFromList: true });
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
modelSelect!.value = "openai/gpt-5-mini";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await flushTasks();
render(renderChatSessionSelect(state), container);
const rerendered = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(rerendered?.value).toBe("openai/gpt-5-mini");
});
it("uses default thinking options when the active session is absent", () => {
const { state } = createChatHeaderState({ omitSessionFromList: true });
state.sessionsResult = createSessionsListResult({
defaultsModel: "gpt-5.5",
defaultsProvider: "openai-codex",
defaultsThinkingLevels: [
{ id: "off", label: "off" },
{ id: "adaptive", label: "adaptive" },
{ id: "xhigh", label: "xhigh" },
{ id: "max", label: "maximum" },
],
omitSessionFromList: true,
});
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const thinkingSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-thinking-select="true"]',
);
const options = [...(thinkingSelect?.options ?? [])].map((option) => option.value);
expect(options).toContain("adaptive");
expect(options).toContain("xhigh");
expect(options).toContain("max");
expect(
[...(thinkingSelect?.options ?? [])]
.find((option) => option.value === "max")
?.textContent?.trim(),
).toBe("maximum");
});
});

View File

@@ -1,94 +0,0 @@
/* @vitest-environment jsdom */
import { html, render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { renderCompactionIndicator, renderFallbackIndicator } from "./status-indicators.ts";
vi.mock("../icons.ts", () => ({
icons: {},
}));
describe("chat status indicators", () => {
it("renders compaction and fallback indicators while they are fresh", () => {
const container = document.createElement("div");
const nowSpy = vi.spyOn(Date, "now");
const renderIndicators = (
compactionStatus: Parameters<typeof renderCompactionIndicator>[0],
fallbackStatus: Parameters<typeof renderFallbackIndicator>[0],
) => {
render(
html`${renderFallbackIndicator(fallbackStatus)}
${renderCompactionIndicator(compactionStatus)}`,
container,
);
};
try {
nowSpy.mockReturnValue(1_000);
renderIndicators(
{
phase: "active",
runId: "run-1",
startedAt: 1_000,
completedAt: null,
},
{
selected: "fireworks/minimax-m2p5",
active: "deepinfra/moonshotai/Kimi-K2.5",
attempts: ["fireworks/minimax-m2p5: rate limit"],
occurredAt: 900,
},
);
let indicator = container.querySelector(".compaction-indicator--active");
expect(indicator).not.toBeNull();
expect(indicator?.textContent).toContain("Compacting context...");
indicator = container.querySelector(".compaction-indicator--fallback");
expect(indicator).not.toBeNull();
expect(indicator?.textContent).toContain("Fallback active: deepinfra/moonshotai/Kimi-K2.5");
renderIndicators(
{
phase: "complete",
runId: "run-1",
startedAt: 900,
completedAt: 900,
},
{
phase: "cleared",
selected: "fireworks/minimax-m2p5",
active: "fireworks/minimax-m2p5",
previous: "deepinfra/moonshotai/Kimi-K2.5",
attempts: [],
occurredAt: 900,
},
);
indicator = container.querySelector(".compaction-indicator--complete");
expect(indicator).not.toBeNull();
expect(indicator?.textContent).toContain("Context compacted");
indicator = container.querySelector(".compaction-indicator--fallback-cleared");
expect(indicator).not.toBeNull();
expect(indicator?.textContent).toContain("Fallback cleared: fireworks/minimax-m2p5");
nowSpy.mockReturnValue(20_000);
renderIndicators(
{
phase: "complete",
runId: "run-1",
startedAt: 0,
completedAt: 0,
},
{
selected: "fireworks/minimax-m2p5",
active: "deepinfra/moonshotai/Kimi-K2.5",
attempts: [],
occurredAt: 0,
},
);
expect(container.querySelector(".compaction-indicator--fallback")).toBeNull();
expect(container.querySelector(".compaction-indicator--complete")).toBeNull();
} finally {
nowSpy.mockRestore();
}
});
});

View File

@@ -1,188 +0,0 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { renderToolCard } from "./tool-cards.ts";
vi.mock("../icons.ts", () => ({
icons: {},
}));
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("renders expanded cards with inline input and output sections", () => {
const container = document.createElement("div");
const toggle = vi.fn();
render(
renderToolCard(
{
id: "msg:4:call-4",
name: "browser.open",
args: { url: "https://example.com" },
inputText: '{\n "url": "https://example.com"\n}',
outputText: "Opened page",
},
{ expanded: true, onToggleExpanded: toggle },
),
container,
);
expect(container.textContent).toContain("Tool input");
expect(container.textContent).toContain("Tool output");
expect(container.textContent).toContain("https://example.com");
expect(container.textContent).toContain("Opened page");
});
it("renders expanded tool calls without an inline output block when no output is present", () => {
const container = document.createElement("div");
render(
renderToolCard(
{
id: "msg:4b:call-4b",
name: "sessions_spawn",
args: { mode: "session", thread: true },
inputText: '{\n "mode": "session",\n "thread": true\n}',
},
{ expanded: true, onToggleExpanded: vi.fn() },
),
container,
);
expect(container.textContent).toContain("Tool input");
expect(container.textContent).toContain('"thread": true');
expect(container.textContent).not.toContain("Tool output");
expect(container.textContent).not.toContain("No output");
});
it("labels collapsed tool calls as tool call", () => {
const container = document.createElement("div");
render(
renderToolCard(
{
id: "msg:5:call-5",
name: "sessions_spawn",
args: { mode: "run" },
inputText: '{\n "mode": "run"\n}',
},
{ expanded: false, onToggleExpanded: vi.fn() },
),
container,
);
expect(container.textContent).toContain("Tool call");
expect(container.textContent).not.toContain("Tool input");
const summaryButton = container.querySelector("button.chat-tool-msg-summary");
expect(summaryButton).not.toBeNull();
expect(summaryButton?.getAttribute("aria-expanded")).toBe("false");
});
it("keeps raw details for legacy canvas tool output without rendering tool-row previews", () => {
const container = document.createElement("div");
render(
renderToolCard(
{
id: "msg:view:7",
name: "canvas_render",
outputText: JSON.stringify({
kind: "canvas",
view: {
backend: "canvas",
id: "cv_counter",
url: "/__openclaw__/canvas/documents/cv_counter/index.html",
title: "Counter demo",
preferred_height: 480,
},
presentation: {
target: "tool_card",
},
}),
preview: {
kind: "canvas",
surface: "assistant_message",
render: "url",
viewId: "cv_counter",
title: "Counter demo",
url: "/__openclaw__/canvas/documents/cv_counter/index.html",
preferredHeight: 480,
},
},
{ expanded: true, onToggleExpanded: vi.fn() },
),
container,
);
const rawToggle = container.querySelector<HTMLButtonElement>(".chat-tool-card__raw-toggle");
const rawBody = container.querySelector<HTMLElement>(".chat-tool-card__raw-body");
expect(container.textContent).toContain("Counter demo");
expect(container.querySelector(".chat-tool-card__preview-frame")).toBeNull();
expect(rawToggle?.getAttribute("aria-expanded")).toBe("false");
expect(rawBody?.hidden).toBe(true);
rawToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(rawToggle?.getAttribute("aria-expanded")).toBe("true");
expect(rawBody?.hidden).toBe(false);
expect(rawBody?.textContent).toContain('"kind":"canvas"');
});
it("opens assistant-surface canvas payloads in the sidebar when explicitly requested", () => {
const container = document.createElement("div");
const onOpenSidebar = vi.fn();
render(
renderToolCard(
{
id: "msg:view:8",
name: "canvas_render",
outputText: JSON.stringify({
kind: "canvas",
view: {
backend: "canvas",
id: "cv_sidebar",
url: "/__openclaw__/canvas/documents/cv_sidebar/index.html",
title: "Player",
preferred_height: 360,
},
presentation: {
target: "assistant_message",
},
}),
preview: {
kind: "canvas",
surface: "assistant_message",
render: "url",
viewId: "cv_sidebar",
url: "/__openclaw__/canvas/documents/cv_sidebar/index.html",
title: "Player",
preferredHeight: 360,
},
},
{ expanded: true, onToggleExpanded: vi.fn(), onOpenSidebar },
),
container,
);
const sidebarButton = container.querySelector<HTMLButtonElement>(".chat-tool-card__action-btn");
sidebarButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(sidebarButton).not.toBeNull();
expect(onOpenSidebar).toHaveBeenCalledWith(
expect.objectContaining({
kind: "canvas",
docId: "cv_sidebar",
entryUrl: "/__openclaw__/canvas/documents/cv_sidebar/index.html",
}),
);
});
});

View File

@@ -1,31 +1,74 @@
/* @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,
createSessionsListResult,
DEFAULT_CHAT_MODEL_CATALOG,
} from "../chat-model.test-helpers.ts";
import { renderChatAvatar } from "../chat/chat-avatar.ts";
import { renderChatQueue } from "../chat/chat-queue.ts";
import { buildRawSidebarContent } from "../chat/chat-sidebar-raw.ts";
import { renderWelcomeState } from "../chat/chat-welcome.ts";
import { renderChatSessionSelect } from "../chat/session-controls.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import type { ModelCatalogEntry } from "../types.ts";
import type { ChatQueueItem } from "../ui-types.ts";
const refreshVisibleToolsEffectiveForCurrentSessionMock = vi.hoisted(() =>
vi.fn(async (state: AppViewState) => {
const agentId = state.agentsSelectedId ?? "main";
const sessionKey = state.sessionKey;
await state.client?.request("tools.effective", { agentId, sessionKey });
const override = state.chatModelOverrides[sessionKey];
state.toolsEffectiveResultKey = `${agentId}:${sessionKey}:model=${override?.value ?? "(default)"}`;
state.toolsEffectiveResult = { agentId, profile: "coding", groups: [] };
}),
);
const loadSessionsMock = vi.hoisted(() =>
vi.fn(async (state: AppViewState) => {
const res = await state.client?.request("sessions.list", {
includeGlobal: true,
includeUnknown: true,
});
if (res) {
state.sessionsResult = res as AppViewState["sessionsResult"];
}
}),
);
vi.mock("../icons.ts", () => ({
icons: {},
}));
vi.mock("../controllers/agents.ts", () => ({
refreshVisibleToolsEffectiveForCurrentSession: refreshVisibleToolsEffectiveForCurrentSessionMock,
}));
vi.mock("../controllers/sessions.ts", () => ({
loadSessions: loadSessionsMock,
}));
vi.mock("./agents-utils.ts", () => ({
isRenderableControlUiAvatarUrl: (value: string) =>
/^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//")),
agentLogoUrl: () => "/openclaw-logo.svg",
assistantAvatarFallbackUrl: () => "apple-touch-icon.png",
resolveChatAvatarRenderUrl: (
candidate: string | null | undefined,
agent: { identity?: { avatar?: string; avatarUrl?: string } },
) => {
const isRenderableControlUiAvatarUrl = (value: string) =>
/^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//"));
if (typeof candidate === "string" && candidate.startsWith("blob:")) {
return candidate;
}
if (
typeof agent.identity?.avatarUrl === "string" &&
agent.identity.avatarUrl.startsWith("blob:")
) {
return agent.identity.avatarUrl;
for (const value of [candidate, agent.identity?.avatarUrl, agent.identity?.avatar]) {
if (typeof value === "string" && isRenderableControlUiAvatarUrl(value)) {
return value;
}
}
return null;
},
@@ -37,6 +80,12 @@ vi.mock("./agents-utils.ts", () => ({
},
}));
function renderAvatar(params: Parameters<typeof renderChatAvatar>) {
const container = document.createElement("div");
render(renderChatAvatar(...params), container);
return container.querySelector<HTMLElement>(".chat-avatar");
}
function renderQueue(params: {
queue: ChatQueueItem[];
canAbort?: boolean;
@@ -55,6 +104,135 @@ function renderQueue(params: {
return container;
}
function createChatHeaderState(
overrides: {
model?: string | null;
modelProvider?: string | null;
models?: ModelCatalogEntry[];
omitSessionFromList?: boolean;
} = {},
): { state: AppViewState; request: ReturnType<typeof vi.fn> } {
let currentModel = overrides.model ?? null;
let currentModelProvider = overrides.modelProvider ?? (currentModel ? "openai" : null);
const omitSessionFromList = overrides.omitSessionFromList ?? false;
const catalog = overrides.models ?? createModelCatalog(...DEFAULT_CHAT_MODEL_CATALOG);
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
if (method === "sessions.patch") {
const nextModel = (params.model as string | null | undefined) ?? null;
if (!nextModel) {
currentModel = null;
currentModelProvider = null;
} else {
const normalized = nextModel.trim();
const slashIndex = normalized.indexOf("/");
if (slashIndex > 0) {
currentModelProvider = normalized.slice(0, slashIndex);
currentModel = normalized.slice(slashIndex + 1);
} else {
currentModel = normalized;
const matchingProviders = catalog
.filter((entry) => entry.id === normalized)
.map((entry) => entry.provider)
.filter(Boolean);
currentModelProvider =
matchingProviders.length === 1 ? matchingProviders[0] : currentModelProvider;
}
}
return { ok: true, key: "main" };
}
if (method === "chat.history") {
return { messages: [], thinkingLevel: null };
}
if (method === "sessions.list") {
return createSessionsListResult({
model: currentModel,
modelProvider: currentModelProvider,
omitSessionFromList,
});
}
if (method === "models.list") {
return { models: catalog };
}
if (method === "tools.effective") {
return {
agentId: "main",
profile: "coding",
groups: [],
};
}
throw new Error(`Unexpected request: ${method}`);
});
const state = {
sessionKey: "main",
connected: true,
sessionsHideCron: true,
sessionsResult: createSessionsListResult({
model: currentModel,
modelProvider: currentModelProvider,
omitSessionFromList,
}),
chatModelOverrides: {},
chatModelCatalog: catalog,
chatModelsLoading: false,
client: { request } as unknown as GatewayBrowserClient,
settings: {
gatewayUrl: "",
token: "",
locale: "en",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "dark",
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
borderRadius: 50,
chatFocusMode: false,
chatShowThinking: false,
},
chatMessage: "",
chatStream: null,
chatStreamStartedAt: null,
chatRunId: null,
chatQueue: [],
chatMessages: [],
chatLoading: false,
chatThinkingLevel: null,
lastError: null,
chatAvatarUrl: null,
basePath: "",
hello: null,
agentsList: null,
agentsPanel: "overview",
agentsSelectedId: null,
toolsEffectiveLoading: false,
toolsEffectiveLoadingKey: null,
toolsEffectiveResultKey: null,
toolsEffectiveError: null,
toolsEffectiveResult: null,
applySettings(next: AppViewState["settings"]) {
state.settings = next;
},
loadAssistantIdentity: vi.fn(),
resetToolStream: vi.fn(),
resetChatScroll: vi.fn(),
} as unknown as AppViewState & {
client: GatewayBrowserClient;
settings: AppViewState["settings"];
};
return { state, request };
}
async function flushTasks() {
await vi.dynamicImportSettled();
}
afterEach(() => {
loadSessionsMock.mockClear();
refreshVisibleToolsEffectiveForCurrentSessionMock.mockClear();
vi.unstubAllGlobals();
});
describe("chat queue", () => {
it("renders Steer only for queued messages during an active run", () => {
const onQueueSteer = vi.fn();
@@ -75,16 +253,62 @@ describe("chat queue", () => {
steerButtons[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onQueueSteer).toHaveBeenCalledWith("queued-1");
});
it("hides queued-message Steer when no run is active", () => {
const container = renderQueue({
const inactiveContainer = renderQueue({
canAbort: false,
onQueueSteer: vi.fn(),
queue: [{ id: "queued-1", text: "tighten the plan", createdAt: 1 }],
});
expect(container.querySelector(".chat-queue__steer")).toBeNull();
expect(inactiveContainer.querySelector(".chat-queue__steer")).toBeNull();
});
});
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("apple-touch-icon.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("apple-touch-icon.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("apple-touch-icon.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");
});
});
@@ -125,34 +349,176 @@ describe("chat welcome", () => {
return container;
}
it("renders configured assistant text avatars in the welcome state", () => {
const container = renderWelcome({ assistantAvatar: "VC", assistantAvatarUrl: null });
it("renders configured assistant avatars and fallback in the welcome state", () => {
let 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 the welcome state", () => {
const container = renderWelcome({
container = renderWelcome({
assistantAvatar: "avatars/val.png",
assistantAvatarUrl: "blob:identity-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");
});
const imageAvatar = container.querySelector<HTMLImageElement>("img");
expect(imageAvatar).not.toBeNull();
expect(imageAvatar?.getAttribute("src")).toBe("blob:identity-avatar");
expect(imageAvatar?.getAttribute("alt")).toBe("Val");
it("uses the Molty png as the welcome fallback assistant avatar", () => {
const container = renderWelcome({ assistantAvatar: null, assistantAvatarUrl: null });
container = renderWelcome({ assistantAvatar: null, assistantAvatarUrl: null });
const avatar = container.querySelector<HTMLImageElement>(".agent-chat__avatar--logo img");
expect(avatar).not.toBeNull();
expect(avatar?.getAttribute("src")).toBe("apple-touch-icon.png");
expect(avatar?.getAttribute("alt")).toBe("Val");
const fallbackAvatar = container.querySelector<HTMLImageElement>(
".agent-chat__avatar--logo img",
);
expect(fallbackAvatar).not.toBeNull();
expect(fallbackAvatar?.getAttribute("src")).toBe("apple-touch-icon.png");
expect(fallbackAvatar?.getAttribute("alt")).toBe("Val");
});
});
describe("chat session controls", () => {
it("patches the current session model from the chat header picker", async () => {
const { state, request } = createChatHeaderState();
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
expect(modelSelect?.value).toBe("");
modelSelect!.value = "openai/gpt-5-mini";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "main",
model: "openai/gpt-5-mini",
});
expect(request).not.toHaveBeenCalledWith("chat.history", expect.anything());
await flushTasks();
expect(loadSessionsMock).toHaveBeenCalledTimes(1);
expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini");
expect(state.sessionsResult?.sessions[0]?.modelProvider).toBe("openai");
});
it("reloads effective tools after a chat-header model switch for the active tools panel", async () => {
const { state, request } = createChatHeaderState();
state.agentsPanel = "tools";
state.agentsSelectedId = "main";
state.toolsEffectiveResultKey = "main:main";
state.toolsEffectiveResult = {
agentId: "main",
profile: "coding",
groups: [],
};
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
modelSelect!.value = "openai/gpt-5-mini";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await flushTasks();
expect(request).toHaveBeenCalledWith("tools.effective", {
agentId: "main",
sessionKey: "main",
});
expect(state.toolsEffectiveResultKey).toBe("main:main:model=openai/gpt-5-mini");
});
it("clears the session model override back to the default model", async () => {
const { state, request } = createChatHeaderState({ model: "gpt-5-mini" });
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
expect(modelSelect?.value).toBe("openai/gpt-5-mini");
modelSelect!.value = "";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "main",
model: null,
});
await flushTasks();
expect(loadSessionsMock).toHaveBeenCalledTimes(1);
expect(state.sessionsResult?.sessions[0]?.model).toBeUndefined();
});
it("disables the chat header model picker while a run is active", () => {
const { state } = createChatHeaderState();
state.chatRunId = "run-123";
state.chatStream = "Working";
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
expect(modelSelect?.disabled).toBe(true);
});
it("keeps the selected model visible when the active session is absent from sessions.list", async () => {
const { state } = createChatHeaderState({ omitSessionFromList: true });
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
modelSelect!.value = "openai/gpt-5-mini";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await flushTasks();
render(renderChatSessionSelect(state), container);
const rerendered = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(rerendered?.value).toBe("openai/gpt-5-mini");
});
it("uses default thinking options when the active session is absent", () => {
const { state } = createChatHeaderState({ omitSessionFromList: true });
state.sessionsResult = createSessionsListResult({
defaultsModel: "gpt-5.5",
defaultsProvider: "openai-codex",
defaultsThinkingLevels: [
{ id: "off", label: "off" },
{ id: "adaptive", label: "adaptive" },
{ id: "xhigh", label: "xhigh" },
{ id: "max", label: "maximum" },
],
omitSessionFromList: true,
});
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const thinkingSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-thinking-select="true"]',
);
const options = [...(thinkingSelect?.options ?? [])].map((option) => option.value);
expect(options).toContain("adaptive");
expect(options).toContain("xhigh");
expect(options).toContain("max");
expect(
[...(thinkingSelect?.options ?? [])]
.find((option) => option.value === "max")
?.textContent?.trim(),
).toBe("maximum");
});
});