test: split chat status indicator coverage

This commit is contained in:
Peter Steinberger
2026-04-21 00:07:19 +01:00
parent f2a46ec46f
commit 74178b37be
5 changed files with 349 additions and 339 deletions

View File

@@ -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<BuildChatItemsProps>,
opts: Partial<RenderMessageGroupOptions> = {},
) {
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<HTMLButtonElement>(
".chat-group.user .chat-group-delete",
);
expect(userDeleteButton).not.toBeNull();
userDeleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const userConfirm = userContainer.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>(
".chat-group.assistant .chat-group-delete",
);
expect(assistantDeleteButton).not.toBeNull();
assistantDeleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const assistantConfirm = assistantContainer.querySelector<HTMLElement>(
".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<HTMLIFrameElement>(".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<HTMLIFrameElement>(".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");
});
});

View File

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

@@ -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`
<div
class="compaction-indicator compaction-indicator--active"
role="status"
aria-live="polite"
>
${icons.loader} Compacting context...
</div>
`;
}
if (status.completedAt) {
const elapsed = Date.now() - status.completedAt;
if (elapsed < COMPACTION_TOAST_DURATION_MS) {
return html`
<div
class="compaction-indicator compaction-indicator--complete"
role="status"
aria-live="polite"
>
${icons.check} Context compacted
</div>
`;
}
}
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`
<div class=${className} role="status" aria-live="polite" title=${details}>
${icon} ${message}
</div>
`;
}

View File

@@ -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> = {}): 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<HTMLButtonElement>(
".chat-group.user .chat-group-delete",
);
expect(userDeleteButton).not.toBeNull();
userDeleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
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);
clearDeleteConfirmSkip();
render(
renderChat(
createProps({
messages: [
{
role: "assistant",
content: "hello from assistant",
timestamp: 1000,
},
],
}),
),
container,
);
const assistantDeleteButton = container.querySelector<HTMLButtonElement>(
".chat-group.assistant .chat-group-delete",
);
expect(assistantDeleteButton).not.toBeNull();
assistantDeleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const assistantConfirm = container.querySelector<HTMLElement>(
".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<HTMLIFrameElement>(".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<HTMLIFrameElement>(".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();

View File

@@ -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<string, InputHistory>();
const pinnedMessagesMap = new Map<string, PinnedMessages>();
@@ -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`
<div
class="compaction-indicator compaction-indicator--active"
role="status"
aria-live="polite"
>
${icons.loader} Compacting context...
</div>
`;
}
if (status.completedAt) {
const elapsed = Date.now() - status.completedAt;
if (elapsed < COMPACTION_TOAST_DURATION_MS) {
return html`
<div
class="compaction-indicator compaction-indicator--complete"
role="status"
aria-live="polite"
>
${icons.check} Context compacted
</div>
`;
}
}
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`
<div class=${className} role="status" aria-live="polite" title=${details}>
${icon} ${message}
</div>
`;
}
export const __testing = {
renderCompactionIndicator,
renderFallbackIndicator,
};
function generateAttachmentId(): string {
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}