diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index 9bc4423833b..07ff8d252e1 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -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(".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", + }), + ); + }); }); diff --git a/ui/src/ui/chat/run-controls.test.ts b/ui/src/ui/chat/run-controls.test.ts new file mode 100644 index 00000000000..a28c2a08b81 --- /dev/null +++ b/ui/src/ui/chat/run-controls.test.ts @@ -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 { + 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('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( + 'button[title="New session"]', + ); + expect(newSessionButton).not.toBeNull(); + newSessionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onNewSession).toHaveBeenCalledTimes(1); + + const sendButton = container.querySelector('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"); + }); +}); diff --git a/ui/src/ui/chat/run-controls.ts b/ui/src/ui/chat/run-controls.ts new file mode 100644 index 00000000000..a598594f598 --- /dev/null +++ b/ui/src/ui/chat/run-controls.ts @@ -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` +
+ ${props.canAbort + ? nothing + : html` + + `} + + + ${props.canAbort + ? html` + + ` + : html` + + `} +
+ `; +} diff --git a/ui/src/ui/chat/tool-expansion-state.test.ts b/ui/src/ui/chat/tool-expansion-state.test.ts new file mode 100644 index 00000000000..abed83b93d7 --- /dev/null +++ b/ui/src/ui/chat/tool-expansion-state.test.ts @@ -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); + }); +}); diff --git a/ui/src/ui/chat/tool-expansion-state.ts b/ui/src/ui/chat/tool-expansion-state.ts new file mode 100644 index 00000000000..7f0a6c8da81 --- /dev/null +++ b/ui/src/ui/chat/tool-expansion-state.ts @@ -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>(); +const initializedToolCardsBySession = new Map>(); +const lastAutoExpandPrefBySession = new Map(); + +export function getExpandedToolCards(sessionKey: string): Map { + return getOrCreateSessionCacheValue(expandedToolCardsBySession, sessionKey, () => new Map()); +} + +function getInitializedToolCards(sessionKey: string): Set { + return getOrCreateSessionCacheValue(initializedToolCardsBySession, sessionKey, () => new Set()); +} + +export function resetToolExpansionStateForTest() { + expandedToolCardsBySession.clear(); + initializedToolCardsBySession.clear(); + lastAutoExpandPrefBySession.clear(); +} + +export function syncToolCardExpansionState( + sessionKey: string, + items: Array, + autoExpandToolCalls: boolean, +) { + const expanded = getExpandedToolCards(sessionKey); + const initialized = getInitializedToolCards(sessionKey); + const previousAutoExpand = lastAutoExpandPrefBySession.get(sessionKey) ?? false; + const currentToolCardIds = new Set(); + 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; + 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); +} diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts deleted file mode 100644 index a1006a2a2e6..00000000000 --- a/ui/src/ui/views/chat.test.ts +++ /dev/null @@ -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``, - }; -}); - -function createSessions(): SessionsListResult { - return { - ts: 0, - path: "", - count: 0, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [], - }; -} - -function createProps(overrides: Partial = {}): 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('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( - '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(".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", - }), - ); - }); -}); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 8bcebf54c9b..b83d8b60172 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -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(); const pinnedMessagesMap = new Map(); const deletedMessagesMap = new Map(); -const expandedToolCardsBySession = new Map>(); -const initializedToolCardsBySession = new Map>(); -const lastAutoExpandPrefBySession = new Map(); 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 { - return getOrCreateSessionCacheValue(expandedToolCardsBySession, sessionKey, () => new Map()); -} - -function getInitializedToolCards(sessionKey: string): Set { - 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, - autoExpandToolCalls: boolean, -) { - const expanded = getExpandedToolCards(sessionKey); - const initialized = getInitializedToolCards(sessionKey); - const previousAutoExpand = lastAutoExpandPrefBySession.get(sessionKey) ?? false; - const currentToolCardIds = new Set(); - 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; - 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`${tokens}` : nothing} -
- ${nothing /* search hidden for now */} - ${canAbort - ? nothing - : html` - - `} - - - ${canAbort - ? html` - - ` - : html` - - `} -
+ ${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), + })}