diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index ae34f59730e..9bc4423833b 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -2,7 +2,9 @@ import { html, render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { getSafeLocalStorage } from "../../local-storage.ts"; import type { MessageGroup } from "../types/chat-types.ts"; +import { buildChatItems, type BuildChatItemsProps } from "./build-chat-items.ts"; import { renderMessageGroup, resetAssistantAttachmentAvailabilityCacheForTest, @@ -102,6 +104,32 @@ function renderMessageGroups( ); } +function renderBuiltMessageGroups( + container: HTMLElement, + props: Partial, + opts: Partial = {}, +) { + const groups = buildChatItems({ + sessionKey: "main", + messages: [], + toolMessages: [], + streamSegments: [], + stream: null, + streamStartedAt: null, + showToolCalls: true, + ...props, + }).filter((item) => item.kind === "group"); + renderMessageGroups(container, groups, opts); +} + +function clearDeleteConfirmSkip() { + try { + getSafeLocalStorage()?.removeItem("openclaw:skipDeleteConfirm"); + } catch { + /* noop */ + } +} + async function flushAssistantAttachmentAvailabilityChecks() { for (let i = 0; i < 6; i++) { await Promise.resolve(); @@ -114,6 +142,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 userContainer = renderDeletable("user"); + const userDeleteButton = userContainer.querySelector( + ".chat-group.user .chat-group-delete", + ); + expect(userDeleteButton).not.toBeNull(); + userDeleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const userConfirm = userContainer.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( + ".chat-group.assistant .chat-group-delete", + ); + expect(assistantDeleteButton).not.toBeNull(); + assistantDeleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const assistantConfirm = assistantContainer.querySelector( + ".chat-group.assistant .chat-delete-confirm", + ); + expect(assistantConfirm).not.toBeNull(); + expect(assistantConfirm?.classList.contains("chat-delete-confirm--right")).toBe(true); + }); + it("keeps inline tool cards collapsed by default and renders expanded state", () => { const container = document.createElement("div"); const message = { @@ -854,4 +926,116 @@ describe("grouped chat rendering", () => { expect(assistantBubble?.textContent).toContain("This item is ready."); expect(assistantBubble?.textContent).toContain("Live history preview"); }); + + it("renders hidden assistant_message canvas results with the configured sandbox", () => { + const container = document.createElement("div"); + const renderCanvas = (params: { embedSandboxMode?: "trusted"; suffix: string }) => + renderBuiltMessageGroups( + container, + { + showToolCalls: false, + messages: [ + { + id: `assistant-canvas-inline-${params.suffix}`, + role: "assistant", + content: [{ type: "text", text: "Inline canvas result." }], + timestamp: Date.now(), + }, + ], + toolMessages: [ + { + id: `tool-artifact-inline-${params.suffix}`, + role: "tool", + toolCallId: `call-artifact-inline-${params.suffix}`, + toolName: "canvas_render", + content: JSON.stringify({ + kind: "canvas", + view: { + backend: "canvas", + id: `cv_inline_${params.suffix}`, + url: `/__openclaw__/canvas/documents/cv_inline_${params.suffix}/index.html`, + title: "Inline demo", + preferred_height: 360, + }, + presentation: { + target: "assistant_message", + }, + }), + timestamp: Date.now() + 1, + }, + ], + }, + { + embedSandboxMode: params.embedSandboxMode ?? "scripts", + }, + ); + + renderCanvas({ suffix: "default" }); + + let iframe = container.querySelector(".chat-tool-card__preview-frame"); + expect(iframe).not.toBeNull(); + expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts"); + expect(iframe?.getAttribute("src")).toBe( + "/__openclaw__/canvas/documents/cv_inline_default/index.html", + ); + expect(container.textContent).toContain("Inline canvas result."); + expect(container.textContent).toContain("Inline demo"); + expect(container.textContent).toContain("Raw details"); + + renderCanvas({ embedSandboxMode: "trusted", suffix: "trusted" }); + iframe = container.querySelector(".chat-tool-card__preview-frame"); + expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts allow-same-origin"); + }); + + it("renders assistant_message canvas results in the assistant bubble even when tool rows are visible", () => { + const container = document.createElement("div"); + renderBuiltMessageGroups( + container, + { + showToolCalls: true, + messages: [ + { + id: "assistant-canvas-inline-visible", + role: "assistant", + content: [{ type: "text", text: "Inline canvas result." }], + timestamp: Date.now(), + }, + ], + toolMessages: [ + { + id: "tool-artifact-inline-visible", + role: "tool", + toolCallId: "call-artifact-inline-visible", + toolName: "canvas_render", + content: JSON.stringify({ + kind: "canvas", + view: { + backend: "canvas", + id: "cv_inline_visible", + url: "/__openclaw__/canvas/documents/cv_inline_visible/index.html", + title: "Inline demo", + preferred_height: 360, + }, + presentation: { + target: "assistant_message", + }, + }), + timestamp: Date.now() + 1, + }, + ], + }, + { + isToolMessageExpanded: () => true, + }, + ); + + const assistantBubble = container.querySelector(".chat-group.assistant .chat-bubble"); + const allPreviews = container.querySelectorAll(".chat-tool-card__preview-frame"); + expect(allPreviews).toHaveLength(1); + expect(assistantBubble?.querySelector(".chat-tool-card__preview-frame")).not.toBeNull(); + expect(container.textContent).toContain("Tool output"); + expect(container.textContent).toContain("canvas_render"); + expect(container.textContent).toContain("Inline canvas result."); + expect(container.textContent).toContain("Inline demo"); + }); }); diff --git a/ui/src/ui/chat/status-indicators.test.ts b/ui/src/ui/chat/status-indicators.test.ts new file mode 100644 index 00000000000..8b3b5552c1e --- /dev/null +++ b/ui/src/ui/chat/status-indicators.test.ts @@ -0,0 +1,90 @@ +/* @vitest-environment jsdom */ + +import { html, render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { renderCompactionIndicator, renderFallbackIndicator } from "./status-indicators.ts"; + +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/status-indicators.ts b/ui/src/ui/chat/status-indicators.ts new file mode 100644 index 00000000000..a46461d5976 --- /dev/null +++ b/ui/src/ui/chat/status-indicators.ts @@ -0,0 +1,72 @@ +import { html, nothing } from "lit"; +import type { CompactionStatus, FallbackStatus } from "../app-tool-stream.ts"; +import { icons } from "../icons.ts"; + +const COMPACTION_TOAST_DURATION_MS = 5000; +const FALLBACK_TOAST_DURATION_MS = 8000; + +export function renderCompactionIndicator(status: CompactionStatus | null | undefined) { + if (!status) { + return nothing; + } + if (status.phase === "active" || status.phase === "retrying") { + return html` +
+ ${icons.loader} Compacting context... +
+ `; + } + if (status.completedAt) { + const elapsed = Date.now() - status.completedAt; + if (elapsed < COMPACTION_TOAST_DURATION_MS) { + return html` +
+ ${icons.check} Context compacted +
+ `; + } + } + return nothing; +} + +export function renderFallbackIndicator(status: FallbackStatus | null | undefined) { + if (!status) { + return nothing; + } + const phase = status.phase ?? "active"; + const elapsed = Date.now() - status.occurredAt; + if (elapsed >= FALLBACK_TOAST_DURATION_MS) { + return nothing; + } + const details = [ + `Selected: ${status.selected}`, + phase === "cleared" ? `Active: ${status.selected}` : `Active: ${status.active}`, + phase === "cleared" && status.previous ? `Previous fallback: ${status.previous}` : null, + status.reason ? `Reason: ${status.reason}` : null, + status.attempts.length > 0 ? `Attempts: ${status.attempts.slice(0, 3).join(" | ")}` : null, + ] + .filter(Boolean) + .join(" • "); + const message = + phase === "cleared" + ? `Fallback cleared: ${status.selected}` + : `Fallback active: ${status.active}`; + const className = + phase === "cleared" + ? "compaction-indicator compaction-indicator--fallback-cleared" + : "compaction-indicator compaction-indicator--fallback"; + const icon = phase === "cleared" ? icons.check : icons.brain; + return html` +
+ ${icon} ${message} +
+ `; +} diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 85d2fbe8b4f..a1006a2a2e6 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -1,10 +1,9 @@ /* @vitest-environment jsdom */ -import { html, render } from "lit"; +import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; -import { getSafeLocalStorage } from "../../local-storage.ts"; import type { SessionsListResult } from "../types.ts"; -import { __testing as chatTesting, renderChat, type ChatProps } from "./chat.ts"; +import { renderChat, type ChatProps } from "./chat.ts"; vi.mock("../markdown.ts", () => ({ toSanitizedMarkdownHtml: (value: string) => value, @@ -91,98 +90,7 @@ function createProps(overrides: Partial = {}): ChatProps { }; } -function clearDeleteConfirmSkip() { - try { - getSafeLocalStorage()?.removeItem("openclaw:skipDeleteConfirm"); - } catch { - /* noop */ - } -} - describe("chat view", () => { - it("renders compaction and fallback indicators while they are fresh", () => { - const container = document.createElement("div"); - const nowSpy = vi.spyOn(Date, "now"); - const renderIndicators = ( - compactionStatus: ChatProps["compactionStatus"], - fallbackStatus: ChatProps["fallbackStatus"], - ) => { - render( - html`${chatTesting.renderFallbackIndicator(fallbackStatus)} - ${chatTesting.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(); - } - }); - it("renders the run action button for abortable and idle states", () => { const container = document.createElement("div"); const onAbort = vi.fn(); @@ -223,65 +131,6 @@ describe("chat view", () => { expect(container.textContent).not.toContain("Stop"); }); - it("positions delete confirm by message side", () => { - clearDeleteConfirmSkip(); - const container = document.createElement("div"); - render( - renderChat( - createProps({ - messages: [ - { - role: "user", - content: "hello from user", - timestamp: 1000, - }, - ], - }), - ), - container, - ); - - const userDeleteButton = container.querySelector( - ".chat-group.user .chat-group-delete", - ); - expect(userDeleteButton).not.toBeNull(); - userDeleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - - const userConfirm = container.querySelector( - ".chat-group.user .chat-delete-confirm", - ); - expect(userConfirm).not.toBeNull(); - expect(userConfirm?.classList.contains("chat-delete-confirm--left")).toBe(true); - - clearDeleteConfirmSkip(); - render( - renderChat( - createProps({ - messages: [ - { - role: "assistant", - content: "hello from assistant", - timestamp: 1000, - }, - ], - }), - ), - container, - ); - - const assistantDeleteButton = container.querySelector( - ".chat-group.assistant .chat-group-delete", - ); - expect(assistantDeleteButton).not.toBeNull(); - assistantDeleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - - const assistantConfirm = container.querySelector( - ".chat-group.assistant .chat-delete-confirm", - ); - expect(assistantConfirm).not.toBeNull(); - expect(assistantConfirm?.classList.contains("chat-delete-confirm--right")).toBe(true); - }); - it("expands already-visible tool cards when auto-expand is turned on", () => { const container = document.createElement("div"); const baseProps = createProps({ @@ -317,118 +166,6 @@ describe("chat view", () => { expect(container.textContent).toContain("Tool output"); }); - it("renders hidden assistant_message canvas results with the configured sandbox", () => { - const container = document.createElement("div"); - const renderCanvas = (params: { embedSandboxMode?: "trusted"; suffix: string }) => - render( - renderChat( - createProps({ - ...(params.embedSandboxMode ? { embedSandboxMode: params.embedSandboxMode } : {}), - showToolCalls: false, - messages: [ - { - id: `assistant-canvas-inline-${params.suffix}`, - role: "assistant", - content: [{ type: "text", text: "Inline canvas result." }], - timestamp: Date.now(), - }, - ], - toolMessages: [ - { - id: `tool-artifact-inline-${params.suffix}`, - role: "tool", - toolCallId: `call-artifact-inline-${params.suffix}`, - toolName: "canvas_render", - content: JSON.stringify({ - kind: "canvas", - view: { - backend: "canvas", - id: `cv_inline_${params.suffix}`, - url: `/__openclaw__/canvas/documents/cv_inline_${params.suffix}/index.html`, - title: "Inline demo", - preferred_height: 360, - }, - presentation: { - target: "assistant_message", - }, - }), - timestamp: Date.now() + 1, - }, - ], - }), - ), - container, - ); - - renderCanvas({ suffix: "default" }); - - let iframe = container.querySelector(".chat-tool-card__preview-frame"); - expect(iframe).not.toBeNull(); - expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts"); - expect(iframe?.getAttribute("src")).toBe( - "/__openclaw__/canvas/documents/cv_inline_default/index.html", - ); - expect(container.textContent).toContain("Inline canvas result."); - expect(container.textContent).toContain("Inline demo"); - expect(container.textContent).toContain("Raw details"); - - renderCanvas({ embedSandboxMode: "trusted", suffix: "trusted" }); - iframe = container.querySelector(".chat-tool-card__preview-frame"); - expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts allow-same-origin"); - }); - - it("renders assistant_message canvas results in the assistant bubble even when tool rows are visible", () => { - const container = document.createElement("div"); - render( - renderChat( - createProps({ - showToolCalls: true, - autoExpandToolCalls: true, - messages: [ - { - id: "assistant-canvas-inline-visible", - role: "assistant", - content: [{ type: "text", text: "Inline canvas result." }], - timestamp: Date.now(), - }, - ], - toolMessages: [ - { - id: "tool-artifact-inline-visible", - role: "tool", - toolCallId: "call-artifact-inline-visible", - toolName: "canvas_render", - content: JSON.stringify({ - kind: "canvas", - view: { - backend: "canvas", - id: "cv_inline_visible", - url: "/__openclaw__/canvas/documents/cv_inline_visible/index.html", - title: "Inline demo", - preferred_height: 360, - }, - presentation: { - target: "assistant_message", - }, - }), - timestamp: Date.now() + 1, - }, - ], - }), - ), - container, - ); - - const assistantBubble = container.querySelector(".chat-group.assistant .chat-bubble"); - const allPreviews = container.querySelectorAll(".chat-tool-card__preview-frame"); - expect(allPreviews).toHaveLength(1); - expect(assistantBubble?.querySelector(".chat-tool-card__preview-frame")).not.toBeNull(); - expect(container.textContent).toContain("Tool output"); - expect(container.textContent).toContain("canvas_render"); - expect(container.textContent).toContain("Inline canvas result."); - expect(container.textContent).toContain("Inline demo"); - }); - it("opens generic tool details instead of a canvas preview from tool rows", async () => { const container = document.createElement("div"); const onOpenSidebar = vi.fn(); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index e505ec300ae..8bcebf54c9b 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -31,6 +31,7 @@ import { type SlashCommandDef, } from "../chat/slash-commands.ts"; import { isSttSupported, startStt, stopStt } from "../chat/speech.ts"; +import { renderCompactionIndicator, renderFallbackIndicator } from "../chat/status-indicators.ts"; import { buildSidebarContent, extractToolCards } from "../chat/tool-cards.ts"; import type { EmbedSandboxMode } from "../embed-sandbox.ts"; import { icons } from "../icons.ts"; @@ -111,9 +112,6 @@ export type ChatProps = { basePath?: string; }; -const COMPACTION_TOAST_DURATION_MS = 5000; -const FALLBACK_TOAST_DURATION_MS = 8000; - // Persistent instances keyed by session const inputHistories = new Map(); const pinnedMessagesMap = new Map(); @@ -256,77 +254,6 @@ function syncToolCardExpansionState( lastAutoExpandPrefBySession.set(sessionKey, autoExpandToolCalls); } -function renderCompactionIndicator(status: CompactionStatus | null | undefined) { - if (!status) { - return nothing; - } - if (status.phase === "active" || status.phase === "retrying") { - return html` -
- ${icons.loader} Compacting context... -
- `; - } - if (status.completedAt) { - const elapsed = Date.now() - status.completedAt; - if (elapsed < COMPACTION_TOAST_DURATION_MS) { - return html` -
- ${icons.check} Context compacted -
- `; - } - } - return nothing; -} - -function renderFallbackIndicator(status: FallbackStatus | null | undefined) { - if (!status) { - return nothing; - } - const phase = status.phase ?? "active"; - const elapsed = Date.now() - status.occurredAt; - if (elapsed >= FALLBACK_TOAST_DURATION_MS) { - return nothing; - } - const details = [ - `Selected: ${status.selected}`, - phase === "cleared" ? `Active: ${status.selected}` : `Active: ${status.active}`, - phase === "cleared" && status.previous ? `Previous fallback: ${status.previous}` : null, - status.reason ? `Reason: ${status.reason}` : null, - status.attempts.length > 0 ? `Attempts: ${status.attempts.slice(0, 3).join(" | ")}` : null, - ] - .filter(Boolean) - .join(" • "); - const message = - phase === "cleared" - ? `Fallback cleared: ${status.selected}` - : `Fallback active: ${status.active}`; - const className = - phase === "cleared" - ? "compaction-indicator compaction-indicator--fallback-cleared" - : "compaction-indicator compaction-indicator--fallback"; - const icon = phase === "cleared" ? icons.check : icons.brain; - return html` -
- ${icon} ${message} -
- `; -} - -export const __testing = { - renderCompactionIndicator, - renderFallbackIndicator, -}; - function generateAttachmentId(): string { return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; }