diff --git a/ui/src/ui/app-lifecycle.node.test.ts b/ui/src/ui/app-lifecycle.node.test.ts index b15a13eb069..8a29dbcb189 100644 --- a/ui/src/ui/app-lifecycle.node.test.ts +++ b/ui/src/ui/app-lifecycle.node.test.ts @@ -1,45 +1,150 @@ -import { describe, expect, it, vi } from "vitest"; -import { handleDisconnected } from "./app-lifecycle.ts"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { scheduleChatScrollMock, scheduleLogsScrollMock, scheduleMermaidRenderMock } = vi.hoisted( + () => ({ + scheduleChatScrollMock: vi.fn(), + scheduleLogsScrollMock: vi.fn(), + scheduleMermaidRenderMock: vi.fn(), + }), +); + +vi.mock("./app-scroll.ts", () => ({ + observeTopbar: vi.fn(), + scheduleChatScroll: scheduleChatScrollMock, + scheduleLogsScroll: scheduleLogsScrollMock, +})); + +vi.mock("./mermaid.ts", () => ({ + scheduleMermaidRender: scheduleMermaidRenderMock, +})); + +import { handleDisconnected, handleUpdated } from "./app-lifecycle.ts"; + +type TestHost = { + basePath: string; + client: { stop: () => void } | null; + connectGeneration: number; + connected: boolean; + tab: "chat"; + assistantName: string; + assistantAvatar: null; + assistantAgentId: null; + serverVersion: null; + chatHasAutoScrolled: boolean; + chatManualRefreshInFlight: boolean; + chatLoading: boolean; + chatMessages: unknown[]; + chatToolMessages: unknown[]; + chatStream: string | null; + settings: { + chatShowThinking: boolean; + chatShowToolCalls: boolean; + }; + logsAutoFollow: boolean; + logsAtBottom: boolean; + logsEntries: unknown[]; + popStateHandler: ReturnType; + topbarObserver: ResizeObserver | null; +}; function createHost() { - return { + const host: TestHost = { basePath: "", - client: { stop: vi.fn() }, + client: null, connectGeneration: 0, - connected: true, + connected: false, tab: "chat", assistantName: "OpenClaw", assistantAvatar: null, assistantAgentId: null, + serverVersion: null, chatHasAutoScrolled: false, chatManualRefreshInFlight: false, chatLoading: false, chatMessages: [], chatToolMessages: [], chatStream: null, + settings: { + chatShowThinking: true, + chatShowToolCalls: true, + }, logsAutoFollow: false, logsAtBottom: true, logsEntries: [], popStateHandler: vi.fn(), - topbarObserver: { disconnect: vi.fn() } as unknown as ResizeObserver, + topbarObserver: null, }; + + return host; } +describe("handleUpdated", () => { + beforeEach(() => { + scheduleChatScrollMock.mockReset(); + scheduleLogsScrollMock.mockReset(); + scheduleMermaidRenderMock.mockReset(); + }); + + it("rerenders Mermaid blocks when chat visibility settings change", () => { + const host = createHost(); + host.settings = { + chatShowThinking: false, + chatShowToolCalls: true, + }; + const changed = new Map([ + [ + "settings", + { + chatShowThinking: true, + chatShowToolCalls: true, + }, + ], + ]); + + handleUpdated(host as never, changed); + + expect(scheduleChatScrollMock).toHaveBeenCalledOnce(); + expect(scheduleMermaidRenderMock).toHaveBeenCalledOnce(); + }); + + it("does not rerender Mermaid blocks for unrelated settings changes", () => { + const host = createHost(); + const changed = new Map([ + [ + "settings", + { + chatShowThinking: true, + chatShowToolCalls: true, + locale: "en", + }, + ], + ]); + + handleUpdated(host as never, changed); + + expect(scheduleChatScrollMock).not.toHaveBeenCalled(); + expect(scheduleMermaidRenderMock).not.toHaveBeenCalled(); + }); +}); + describe("handleDisconnected", () => { it("stops and clears gateway client on teardown", () => { const removeSpy = vi.spyOn(window, "removeEventListener").mockImplementation(() => undefined); const host = createHost(); - const disconnectSpy = ( - host.topbarObserver as unknown as { disconnect: ReturnType } - ).disconnect; + const stop = vi.fn(); + const disconnect = vi.fn(); + host.client = { stop }; + host.connected = true; + host.topbarObserver = { disconnect } as unknown as ResizeObserver; - handleDisconnected(host as unknown as Parameters[0]); + handleDisconnected(host as never); expect(removeSpy).toHaveBeenCalledWith("popstate", host.popStateHandler); expect(host.connectGeneration).toBe(1); + expect(stop).toHaveBeenCalledTimes(1); expect(host.client).toBeNull(); expect(host.connected).toBe(false); - expect(disconnectSpy).toHaveBeenCalledTimes(1); + expect(disconnect).toHaveBeenCalledTimes(1); expect(host.topbarObserver).toBeNull(); removeSpy.mockRestore(); }); diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index a986d2b6b64..9110113566f 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -36,6 +36,10 @@ type LifecycleHost = { chatMessages: unknown[]; chatToolMessages: unknown[]; chatStream: string | null; + settings?: { + chatShowThinking?: boolean; + chatShowToolCalls?: boolean; + }; logsAutoFollow: boolean; logsAtBottom: boolean; logsEntries: unknown[]; @@ -89,13 +93,20 @@ export function handleUpdated(host: LifecycleHost, changed: Map { expect(html).toContain("Click to enlarge"); }); + it("renders Mermaid fences case-insensitively", () => { + const html = toSanitizedMarkdownHtml( + ["```Mermaid", "flowchart TD", "A --> B", "```"].join("\n"), + ); + + expect(html).toContain('class="mermaid-block"'); + expect(html).toContain('class="language-mermaid"'); + }); + it("flattens remote markdown images into alt text", () => { const html = toSanitizedMarkdownHtml("![Alt text](https://example.com/image.png)"); expect(html).not.toContain(" { if (!mermaidApiPromise) { mermaidApiPromise = import("mermaid") @@ -152,8 +161,10 @@ async function renderMermaidBlocks(root: ParentNode): Promise { for (const block of blocks) { const renderTarget = block.querySelector(".mermaid-block__render"); if (renderTarget) { - renderTarget.textContent = - "Mermaid render failed to load. Reload the page or expand source to inspect diagram text."; + setMermaidRenderError( + renderTarget, + "Mermaid render failed to load. Reload the page or expand source to inspect diagram text.", + ); } block.dataset.mermaidStatus = "error"; } @@ -177,7 +188,10 @@ async function renderMermaidBlocks(root: ParentNode): Promise { block.dataset.mermaidStatus = "ready"; } catch (err) { console.warn("[markdown] mermaid render failed", err); - renderTarget.textContent = "Mermaid render failed. Expand source to inspect diagram text."; + setMermaidRenderError( + renderTarget, + "Mermaid render failed. Expand source to inspect diagram text.", + ); block.dataset.mermaidStatus = "error"; } }