mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:00:42 +00:00
test: split chat status indicator coverage
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
90
ui/src/ui/chat/status-indicators.test.ts
Normal file
90
ui/src/ui/chat/status-indicators.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
72
ui/src/ui/chat/status-indicators.ts
Normal file
72
ui/src/ui/chat/status-indicators.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user