diff --git a/ui/src/ui/chat/chat-avatar.test.ts b/ui/src/ui/chat/chat-avatar.test.ts deleted file mode 100644 index 44b354da58b..00000000000 --- a/ui/src/ui/chat/chat-avatar.test.ts +++ /dev/null @@ -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) { - const container = document.createElement("div"); - render(renderChatAvatar(...params), container); - return container.querySelector(".chat-avatar"); -} - -describe("renderChatAvatar", () => { - it("uses the assistant fallback when no assistant avatar is configured", () => { - const avatar = renderAvatar(["assistant"]); - - expect(avatar).not.toBeNull(); - expect(avatar?.getAttribute("src")).toBe("/openclaw-molty.png"); - }); - - it("renders assistant fallback, blob image, and text avatars", () => { - const remoteAvatar = renderAvatar([ - "assistant", - { avatar: "https://example.com/avatar.png", name: "Val" }, - ]); - expect(remoteAvatar?.getAttribute("src")).toBe("/openclaw-molty.png"); - - const blobAvatar = renderAvatar(["assistant", { avatar: "blob:managed-image", name: "Val" }]); - expect(blobAvatar?.tagName).toBe("IMG"); - expect(blobAvatar?.getAttribute("src")).toBe("blob:managed-image"); - - const textAvatar = renderAvatar(["assistant", { avatar: "VC", name: "Val" }]); - expect(textAvatar?.tagName).toBe("DIV"); - expect(textAvatar?.textContent).toContain("VC"); - expect(textAvatar?.getAttribute("aria-label")).toBe("Val"); - }); - - it("uses the assistant fallback while authenticated avatar routes are loading", () => { - const avatar = renderAvatar([ - "assistant", - { avatar: "/avatar/main", name: "OpenClaw" }, - undefined, - "", - "session-token", - ]); - - expect(avatar?.getAttribute("src")).toBe("/openclaw-molty.png"); - }); - - it("renders local user image and text avatars", () => { - const imageAvatar = renderAvatar(["user", undefined, { name: "Buns", avatar: "/avatar/user" }]); - expect(imageAvatar?.getAttribute("src")).toBe("/avatar/user"); - expect(imageAvatar?.getAttribute("alt")).toBe("Buns"); - - const textAvatar = renderAvatar(["user", undefined, { name: "Buns", avatar: "AB" }]); - expect(textAvatar?.tagName).toBe("DIV"); - expect(textAvatar?.textContent).toContain("AB"); - }); -}); diff --git a/ui/src/ui/chat/context-notice.test.ts b/ui/src/ui/chat/context-notice.test.ts deleted file mode 100644 index 25a6c0a32fe..00000000000 --- a/ui/src/ui/chat/context-notice.test.ts +++ /dev/null @@ -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(".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(".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(".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(".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(); - }); -}); diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index 334a5e61921..28c962f6560 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -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( + const userDeleteButton = container.querySelector( ".chat-group.user .chat-group-delete", ); expect(userDeleteButton).not.toBeNull(); userDeleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - const userConfirm = userContainer.querySelector( + const userConfirm = container.querySelector( ".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( + const assistantDeleteButton = container.querySelector( ".chat-group.assistant .chat-group-delete", ); expect(assistantDeleteButton).not.toBeNull(); assistantDeleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - const assistantConfirm = assistantContainer.querySelector( + const assistantConfirm = container.querySelector( ".chat-group.assistant .chat-delete-confirm", ); expect(assistantConfirm).not.toBeNull(); diff --git a/ui/src/ui/chat/run-controls.test.ts b/ui/src/ui/chat/run-controls.test.ts index 19941b8050c..b5381fe8a3f 100644 --- a/ui/src/ui/chat/run-controls.test.ts +++ b/ui/src/ui/chat/run-controls.test.ts @@ -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 { 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[0], + fallbackStatus: Parameters[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(".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(".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(".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(".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(".chat-tool-card__raw-toggle"); + const rawBody = container.querySelector(".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(".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", + }), + ); + }); +}); diff --git a/ui/src/ui/chat/session-controls.test.ts b/ui/src/ui/chat/session-controls.test.ts deleted file mode 100644 index 161d21d43c8..00000000000 --- a/ui/src/ui/chat/session-controls.test.ts +++ /dev/null @@ -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 } { - 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) => { - 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( - '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( - '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( - '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( - '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( - '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( - '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( - '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"); - }); -}); diff --git a/ui/src/ui/chat/status-indicators.test.ts b/ui/src/ui/chat/status-indicators.test.ts deleted file mode 100644 index 15c1e00df9d..00000000000 --- a/ui/src/ui/chat/status-indicators.test.ts +++ /dev/null @@ -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[0], - fallbackStatus: Parameters[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(); - } - }); -}); diff --git a/ui/src/ui/chat/tool-cards.test.ts b/ui/src/ui/chat/tool-cards.test.ts deleted file mode 100644 index 1c58fa6b66d..00000000000 --- a/ui/src/ui/chat/tool-cards.test.ts +++ /dev/null @@ -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(".chat-tool-card__raw-toggle"); - const rawBody = container.querySelector(".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(".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", - }), - ); - }); -}); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index e314d630022..e774b3c6108 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -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) { + const container = document.createElement("div"); + render(renderChatAvatar(...params), container); + return container.querySelector(".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 } { + 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) => { + 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(".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("img"); - expect(avatar).not.toBeNull(); - expect(avatar?.getAttribute("src")).toBe("blob:identity-avatar"); - expect(avatar?.getAttribute("alt")).toBe("Val"); - }); + const imageAvatar = container.querySelector("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(".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( + ".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( + '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( + '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( + '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( + '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( + '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( + '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( + '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"); }); });