test: split chat view coverage

This commit is contained in:
Peter Steinberger
2026-04-21 00:34:57 +01:00
parent 503af7afa6
commit 92191d37e6
7 changed files with 340 additions and 347 deletions

View File

@@ -1038,4 +1038,61 @@ describe("grouped chat rendering", () => {
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", () => {
const container = document.createElement("div");
const onOpenSidebar = vi.fn();
renderBuiltMessageGroups(
container,
{
showToolCalls: true,
messages: [
{
id: "assistant-canvas-sidebar",
role: "assistant",
content: [{ type: "text", text: "Sidebar canvas result." }],
timestamp: Date.now(),
},
],
toolMessages: [
{
id: "tool-artifact-sidebar",
role: "tool",
toolCallId: "call-artifact-sidebar",
toolName: "canvas_render",
content: JSON.stringify({
kind: "canvas",
view: {
backend: "canvas",
id: "cv_sidebar",
url: "https://example.com/canvas",
title: "Sidebar demo",
preferred_height: 420,
},
presentation: {
target: "tool_card",
},
}),
timestamp: Date.now() + 1,
},
],
},
{
isToolExpanded: () => true,
isToolMessageExpanded: () => true,
onOpenSidebar,
},
);
const sidebarButton = container.querySelector<HTMLButtonElement>(".chat-tool-card__action-btn");
sidebarButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(container.querySelector(".chat-tool-card__preview-frame")).toBeNull();
expect(sidebarButton).not.toBeNull();
expect(onOpenSidebar).toHaveBeenCalledWith(
expect.objectContaining({
kind: "markdown",
}),
);
});
});

View File

@@ -0,0 +1,75 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { renderChatRunControls, type ChatRunControlsProps } from "./run-controls.ts";
function createProps(overrides: Partial<ChatRunControlsProps> = {}): ChatRunControlsProps {
return {
canAbort: false,
connected: true,
draft: "",
hasMessages: false,
isBusy: false,
sending: false,
onAbort: () => undefined,
onExport: () => undefined,
onNewSession: () => undefined,
onSend: () => undefined,
onStoreDraft: () => undefined,
...overrides,
};
}
describe("chat run controls", () => {
it("switches between idle and abort actions", () => {
const container = document.createElement("div");
const onAbort = vi.fn();
render(
renderChatRunControls(
createProps({
canAbort: true,
sending: true,
onAbort,
}),
),
container,
);
const stopButton = container.querySelector<HTMLButtonElement>('button[title="Stop"]');
expect(stopButton).not.toBeNull();
stopButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onAbort).toHaveBeenCalledTimes(1);
expect(container.textContent).not.toContain("New session");
const onNewSession = vi.fn();
const onSend = vi.fn();
const onStoreDraft = vi.fn();
render(
renderChatRunControls(
createProps({
draft: " run this ",
hasMessages: true,
onNewSession,
onSend,
onStoreDraft,
}),
),
container,
);
const newSessionButton = container.querySelector<HTMLButtonElement>(
'button[title="New session"]',
);
expect(newSessionButton).not.toBeNull();
newSessionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onNewSession).toHaveBeenCalledTimes(1);
const sendButton = container.querySelector<HTMLButtonElement>('button[title="Send"]');
expect(sendButton).not.toBeNull();
sendButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onStoreDraft).toHaveBeenCalledWith(" run this ");
expect(onSend).toHaveBeenCalledTimes(1);
expect(container.textContent).not.toContain("Stop");
});
});

View File

@@ -0,0 +1,72 @@
import { html, nothing } from "lit";
import { icons } from "../icons.ts";
export type ChatRunControlsProps = {
canAbort: boolean;
connected: boolean;
draft: string;
hasMessages: boolean;
isBusy: boolean;
sending: boolean;
onAbort?: () => void;
onExport: () => void;
onNewSession: () => void;
onSend: () => void;
onStoreDraft: (draft: string) => void;
};
export function renderChatRunControls(props: ChatRunControlsProps) {
return html`
<div class="agent-chat__toolbar-right">
${props.canAbort
? nothing
: html`
<button
class="btn btn--ghost"
@click=${props.onNewSession}
title="New session"
aria-label="New session"
>
${icons.plus}
</button>
`}
<button
class="btn btn--ghost"
@click=${props.onExport}
title="Export"
aria-label="Export chat"
?disabled=${!props.hasMessages}
>
${icons.download}
</button>
${props.canAbort
? html`
<button
class="chat-send-btn chat-send-btn--stop"
@click=${props.onAbort}
title="Stop"
aria-label="Stop generating"
>
${icons.stop}
</button>
`
: html`
<button
class="chat-send-btn"
@click=${() => {
if (props.draft.trim()) {
props.onStoreDraft(props.draft);
}
props.onSend();
}}
?disabled=${!props.connected || props.sending}
title=${props.isBusy ? "Queue" : "Send"}
aria-label=${props.isBusy ? "Queue message" : "Send message"}
>
${icons.send}
</button>
`}
</div>
`;
}

View File

@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it } from "vitest";
import type { MessageGroup } from "../types/chat-types.ts";
import {
getExpandedToolCards,
resetToolExpansionStateForTest,
syncToolCardExpansionState,
} from "./tool-expansion-state.ts";
afterEach(() => {
resetToolExpansionStateForTest();
});
function createGroup(message: unknown, key = "assistant-1"): MessageGroup {
return {
kind: "group",
key,
role: "assistant",
messages: [{ key, message }],
timestamp: 1,
isStreaming: false,
};
}
describe("tool expansion state", () => {
it("expands already-visible tool cards when auto-expand turns on", () => {
const group = createGroup({
role: "assistant",
content: [
{
type: "toolcall",
id: "call-1",
name: "browser.open",
arguments: { url: "https://example.com" },
},
],
});
syncToolCardExpansionState("main", [group], false);
expect(getExpandedToolCards("main").get("assistant-1:toolcard:0")).toBe(false);
syncToolCardExpansionState("main", [group], true);
expect(getExpandedToolCards("main").get("assistant-1:toolcard:0")).toBe(true);
});
});

View File

@@ -0,0 +1,76 @@
import type { ChatItem, MessageGroup } from "../types/chat-types.ts";
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer.ts";
import { getOrCreateSessionCacheValue } from "./session-cache.ts";
import { extractToolCards } from "./tool-cards.ts";
const expandedToolCardsBySession = new Map<string, Map<string, boolean>>();
const initializedToolCardsBySession = new Map<string, Set<string>>();
const lastAutoExpandPrefBySession = new Map<string, boolean>();
export function getExpandedToolCards(sessionKey: string): Map<string, boolean> {
return getOrCreateSessionCacheValue(expandedToolCardsBySession, sessionKey, () => new Map());
}
function getInitializedToolCards(sessionKey: string): Set<string> {
return getOrCreateSessionCacheValue(initializedToolCardsBySession, sessionKey, () => new Set());
}
export function resetToolExpansionStateForTest() {
expandedToolCardsBySession.clear();
initializedToolCardsBySession.clear();
lastAutoExpandPrefBySession.clear();
}
export function syncToolCardExpansionState(
sessionKey: string,
items: Array<ChatItem | MessageGroup>,
autoExpandToolCalls: boolean,
) {
const expanded = getExpandedToolCards(sessionKey);
const initialized = getInitializedToolCards(sessionKey);
const previousAutoExpand = lastAutoExpandPrefBySession.get(sessionKey) ?? false;
const currentToolCardIds = new Set<string>();
for (const item of items) {
if (item.kind !== "group") {
continue;
}
for (const entry of item.messages) {
const cards = extractToolCards(entry.message, entry.key);
for (let cardIndex = 0; cardIndex < cards.length; cardIndex++) {
const disclosureId = `${entry.key}:toolcard:${cardIndex}`;
currentToolCardIds.add(disclosureId);
if (initialized.has(disclosureId)) {
continue;
}
expanded.set(disclosureId, autoExpandToolCalls);
initialized.add(disclosureId);
}
const messageRecord = entry.message as Record<string, unknown>;
const role = typeof messageRecord.role === "string" ? messageRecord.role : "unknown";
const normalizedRole = normalizeRoleForGrouping(role);
const isToolMessage =
isToolResultMessage(entry.message) ||
normalizedRole === "tool" ||
role.toLowerCase() === "toolresult" ||
role.toLowerCase() === "tool_result" ||
typeof messageRecord.toolCallId === "string" ||
typeof messageRecord.tool_call_id === "string";
if (!isToolMessage) {
continue;
}
const disclosureId = `toolmsg:${entry.key}`;
currentToolCardIds.add(disclosureId);
if (initialized.has(disclosureId)) {
continue;
}
expanded.set(disclosureId, autoExpandToolCalls);
initialized.add(disclosureId);
}
}
if (autoExpandToolCalls && !previousAutoExpand) {
for (const toolCardId of currentToolCardIds) {
expanded.set(toolCardId, true);
}
}
lastAutoExpandPrefBySession.set(sessionKey, autoExpandToolCalls);
}

View File

@@ -1,227 +0,0 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { SessionsListResult } from "../types.ts";
import { renderChat, type ChatProps } from "./chat.ts";
vi.mock("../markdown.ts", () => ({
toSanitizedMarkdownHtml: (value: string) => value,
}));
vi.mock("../chat/export.ts", () => ({
exportChatMarkdown: vi.fn(),
}));
vi.mock("../chat/speech.ts", () => ({
isSttActive: () => false,
isSttSupported: () => false,
isTtsSpeaking: () => false,
isTtsSupported: () => false,
speakText: () => false,
startStt: () => false,
stopStt: () => undefined,
stopTts: () => undefined,
}));
vi.mock("../components/resizable-divider.ts", () => ({}));
vi.mock("./markdown-sidebar.ts", async () => {
const { html } = await import("lit");
return {
renderMarkdownSidebar: (props: { content?: { content?: string; title?: string } | null }) =>
html`<div class="sidebar-panel" data-mocked-sidebar>
${props.content?.title ?? props.content?.content ?? ""}
</div>`,
};
});
function createSessions(): SessionsListResult {
return {
ts: 0,
path: "",
count: 0,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [],
};
}
function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
return {
sessionKey: "main",
onSessionKeyChange: () => undefined,
thinkingLevel: null,
showThinking: false,
showToolCalls: true,
loading: false,
sending: false,
canAbort: false,
compactionStatus: null,
fallbackStatus: null,
messages: [],
sideResult: null,
toolMessages: [],
streamSegments: [],
stream: null,
streamStartedAt: null,
assistantAvatarUrl: null,
draft: "",
queue: [],
connected: true,
canSend: true,
disabledReason: null,
error: null,
sessions: createSessions(),
focusMode: false,
assistantName: "OpenClaw",
assistantAvatar: null,
localMediaPreviewRoots: [],
onRefresh: () => undefined,
onToggleFocusMode: () => undefined,
onDraftChange: () => undefined,
onSend: () => undefined,
onQueueRemove: () => undefined,
onDismissSideResult: () => undefined,
onNewSession: () => undefined,
agentsList: null,
currentAgentId: "",
onAgentChange: () => undefined,
...overrides,
};
}
describe("chat view", () => {
it("renders the run action button for abortable and idle states", () => {
const container = document.createElement("div");
const onAbort = vi.fn();
render(
renderChat(
createProps({
canAbort: true,
sending: true,
onAbort,
}),
),
container,
);
let stopButton = container.querySelector<HTMLButtonElement>('button[title="Stop"]');
expect(stopButton).not.toBeUndefined();
stopButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onAbort).toHaveBeenCalledTimes(1);
expect(container.textContent).not.toContain("New session");
const onNewSession = vi.fn();
render(
renderChat(
createProps({
canAbort: false,
onNewSession,
}),
),
container,
);
const newSessionButton = container.querySelector<HTMLButtonElement>(
'button[title="New session"]',
);
expect(newSessionButton).not.toBeUndefined();
newSessionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onNewSession).toHaveBeenCalledTimes(1);
expect(container.textContent).not.toContain("Stop");
});
it("expands already-visible tool cards when auto-expand is turned on", () => {
const container = document.createElement("div");
const baseProps = createProps({
messages: [
{
id: "assistant-3",
role: "assistant",
toolCallId: "call-3",
content: [
{
type: "toolcall",
id: "call-3",
name: "browser.open",
arguments: { url: "https://example.com" },
},
{
type: "toolresult",
id: "call-3",
name: "browser.open",
text: "Opened page",
},
],
timestamp: Date.now(),
},
],
});
render(renderChat(baseProps), container);
expect(container.textContent).not.toContain("Input");
render(renderChat({ ...baseProps, autoExpandToolCalls: true }), container);
expect(container.textContent).toContain("Tool input");
expect(container.textContent).toContain("Tool output");
});
it("opens generic tool details instead of a canvas preview from tool rows", async () => {
const container = document.createElement("div");
const onOpenSidebar = vi.fn();
render(
renderChat(
createProps({
showToolCalls: true,
autoExpandToolCalls: true,
onOpenSidebar,
messages: [
{
id: "assistant-canvas-sidebar",
role: "assistant",
content: [{ type: "text", text: "Sidebar canvas result." }],
timestamp: Date.now(),
},
],
toolMessages: [
{
id: "tool-artifact-sidebar",
role: "tool",
toolCallId: "call-artifact-sidebar",
toolName: "canvas_render",
content: JSON.stringify({
kind: "canvas",
view: {
backend: "canvas",
id: "cv_sidebar",
url: "https://example.com/canvas",
title: "Sidebar demo",
preferred_height: 420,
},
presentation: {
target: "tool_card",
},
}),
timestamp: Date.now() + 1,
},
],
}),
),
container,
);
await Promise.resolve();
const sidebarButton = container.querySelector<HTMLButtonElement>(".chat-tool-card__action-btn");
sidebarButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(container.querySelector(".chat-tool-card__preview-frame")).toBeNull();
expect(sidebarButton).not.toBeNull();
expect(onOpenSidebar).toHaveBeenCalledWith(
expect.objectContaining({
kind: "markdown",
}),
);
});
});

View File

@@ -16,9 +16,9 @@ import {
renderStreamingGroup,
} from "../chat/grouped-render.ts";
import { InputHistory } from "../chat/input-history.ts";
import { isToolResultMessage, normalizeRoleForGrouping } from "../chat/message-normalizer.ts";
import { PinnedMessages } from "../chat/pinned-messages.ts";
import { getPinnedMessageSummary } from "../chat/pinned-summary.ts";
import { renderChatRunControls } from "../chat/run-controls.ts";
import { getOrCreateSessionCacheValue } from "../chat/session-cache.ts";
import { renderSideResult } from "../chat/side-result-render.ts";
import type { ChatSideResult } from "../chat/side-result.ts";
@@ -32,13 +32,13 @@ import {
} 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 { buildSidebarContent } from "../chat/tool-cards.ts";
import { getExpandedToolCards, syncToolCardExpansionState } from "../chat/tool-expansion-state.ts";
import type { EmbedSandboxMode } from "../embed-sandbox.ts";
import { icons } from "../icons.ts";
import type { SidebarContent } from "../sidebar-content.ts";
import { detectTextDirection } from "../text-direction.ts";
import type { SessionsListResult } from "../types.ts";
import type { ChatItem, MessageGroup } from "../types/chat-types.ts";
import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts";
import { agentLogoUrl, resolveAgentAvatarUrl } from "./agents-utils.ts";
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
@@ -116,9 +116,6 @@ export type ChatProps = {
const inputHistories = new Map<string, InputHistory>();
const pinnedMessagesMap = new Map<string, PinnedMessages>();
const deletedMessagesMap = new Map<string, DeletedMessages>();
const expandedToolCardsBySession = new Map<string, Map<string, boolean>>();
const initializedToolCardsBySession = new Map<string, Set<string>>();
const lastAutoExpandPrefBySession = new Map<string, boolean>();
function getInputHistory(sessionKey: string): InputHistory {
return getOrCreateSessionCacheValue(inputHistories, sessionKey, () => new InputHistory());
@@ -140,14 +137,6 @@ function getDeletedMessages(sessionKey: string): DeletedMessages {
);
}
function getExpandedToolCards(sessionKey: string): Map<string, boolean> {
return getOrCreateSessionCacheValue(expandedToolCardsBySession, sessionKey, () => new Map());
}
function getInitializedToolCards(sessionKey: string): Set<string> {
return getOrCreateSessionCacheValue(initializedToolCardsBySession, sessionKey, () => new Set());
}
interface ChatEphemeralState {
sttRecording: boolean;
sttInterimText: string;
@@ -200,60 +189,6 @@ function adjustTextareaHeight(el: HTMLTextAreaElement) {
el.style.height = `${Math.min(el.scrollHeight, 150)}px`;
}
function syncToolCardExpansionState(
sessionKey: string,
items: Array<ChatItem | MessageGroup>,
autoExpandToolCalls: boolean,
) {
const expanded = getExpandedToolCards(sessionKey);
const initialized = getInitializedToolCards(sessionKey);
const previousAutoExpand = lastAutoExpandPrefBySession.get(sessionKey) ?? false;
const currentToolCardIds = new Set<string>();
for (const item of items) {
if (item.kind !== "group") {
continue;
}
for (const entry of item.messages) {
const cards = extractToolCards(entry.message, entry.key);
for (let cardIndex = 0; cardIndex < cards.length; cardIndex++) {
const disclosureId = `${entry.key}:toolcard:${cardIndex}`;
currentToolCardIds.add(disclosureId);
if (initialized.has(disclosureId)) {
continue;
}
expanded.set(disclosureId, autoExpandToolCalls);
initialized.add(disclosureId);
}
const messageRecord = entry.message as Record<string, unknown>;
const role = typeof messageRecord.role === "string" ? messageRecord.role : "unknown";
const normalizedRole = normalizeRoleForGrouping(role);
const isToolMessage =
isToolResultMessage(entry.message) ||
normalizedRole === "tool" ||
role.toLowerCase() === "toolresult" ||
role.toLowerCase() === "tool_result" ||
typeof messageRecord.toolCallId === "string" ||
typeof messageRecord.tool_call_id === "string";
if (!isToolMessage) {
continue;
}
const disclosureId = `toolmsg:${entry.key}`;
currentToolCardIds.add(disclosureId);
if (initialized.has(disclosureId)) {
continue;
}
expanded.set(disclosureId, autoExpandToolCalls);
initialized.add(disclosureId);
}
}
if (autoExpandToolCalls && !previousAutoExpand) {
for (const toolCardId of currentToolCardIds) {
expanded.set(toolCardId, true);
}
}
lastAutoExpandPrefBySession.set(sessionKey, autoExpandToolCalls);
}
function generateAttachmentId(): string {
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
@@ -1311,58 +1246,19 @@ export function renderChat(props: ChatProps) {
${tokens ? html`<span class="agent-chat__token-count">${tokens}</span>` : nothing}
</div>
<div class="agent-chat__toolbar-right">
${nothing /* search hidden for now */}
${canAbort
? nothing
: html`
<button
class="btn btn--ghost"
@click=${props.onNewSession}
title="New session"
aria-label="New session"
>
${icons.plus}
</button>
`}
<button
class="btn btn--ghost"
@click=${() => exportMarkdown(props)}
title="Export"
aria-label="Export chat"
?disabled=${props.messages.length === 0}
>
${icons.download}
</button>
${canAbort
? html`
<button
class="chat-send-btn chat-send-btn--stop"
@click=${props.onAbort}
title="Stop"
aria-label="Stop generating"
>
${icons.stop}
</button>
`
: html`
<button
class="chat-send-btn"
@click=${() => {
if (props.draft.trim()) {
inputHistory.push(props.draft);
}
props.onSend();
}}
?disabled=${!props.connected || props.sending}
title=${isBusy ? "Queue" : "Send"}
aria-label=${isBusy ? "Queue message" : "Send message"}
>
${icons.send}
</button>
`}
</div>
${renderChatRunControls({
canAbort,
connected: props.connected,
draft: props.draft,
hasMessages: props.messages.length > 0,
isBusy,
sending: props.sending,
onAbort: props.onAbort,
onExport: () => exportMarkdown(props),
onNewSession: props.onNewSession,
onSend: props.onSend,
onStoreDraft: (draft) => inputHistory.push(draft),
})}
</div>
</div>
</section>