mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:10:44 +00:00
test: split chat view coverage
This commit is contained in:
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
75
ui/src/ui/chat/run-controls.test.ts
Normal file
75
ui/src/ui/chat/run-controls.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
72
ui/src/ui/chat/run-controls.ts
Normal file
72
ui/src/ui/chat/run-controls.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
44
ui/src/ui/chat/tool-expansion-state.test.ts
Normal file
44
ui/src/ui/chat/tool-expansion-state.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
76
ui/src/ui/chat/tool-expansion-state.ts
Normal file
76
ui/src/ui/chat/tool-expansion-state.ts
Normal 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);
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user