control-ui: address Mermaid review feedback

This commit is contained in:
chembo.huang 2026-03-20 17:30:32 +08:00
parent 6aee80074c
commit e8d583efb3
4 changed files with 154 additions and 15 deletions

View File

@ -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<typeof vi.fn>;
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<PropertyKey, unknown>([
[
"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<PropertyKey, unknown>([
[
"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<typeof vi.fn> }
).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<typeof handleDisconnected>[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();
});

View File

@ -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<PropertyKey, unk
if (host.tab === "chat" && host.chatManualRefreshInFlight) {
return;
}
const previousSettings = changed.get("settings") as LifecycleHost["settings"] | undefined;
const chatRenderSettingsChanged =
changed.has("settings") &&
!!previousSettings &&
(previousSettings.chatShowThinking !== host.settings?.chatShowThinking ||
previousSettings.chatShowToolCalls !== host.settings?.chatShowToolCalls);
if (
host.tab === "chat" &&
(changed.has("chatMessages") ||
changed.has("chatToolMessages") ||
changed.has("chatStream") ||
changed.has("chatLoading") ||
changed.has("tab"))
changed.has("tab") ||
chatRenderSettingsChanged)
) {
const forcedByTab = changed.has("tab");
const forcedByLoad =

View File

@ -42,6 +42,15 @@ describe("toSanitizedMarkdownHtml", () => {
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("<img");

View File

@ -15,6 +15,15 @@ const mermaidSanitizeOptions = {
HTML_INTEGRATION_POINTS: { foreignobject: true },
};
function setMermaidRenderError(renderTarget: HTMLElement, message: string) {
renderTarget.removeAttribute("role");
renderTarget.removeAttribute("aria-label");
renderTarget.removeAttribute("tabindex");
renderTarget.removeAttribute("title");
renderTarget.setAttribute("aria-live", "polite");
renderTarget.textContent = message;
}
async function loadMermaidApi(): Promise<MermaidApi> {
if (!mermaidApiPromise) {
mermaidApiPromise = import("mermaid")
@ -152,8 +161,10 @@ async function renderMermaidBlocks(root: ParentNode): Promise<void> {
for (const block of blocks) {
const renderTarget = block.querySelector<HTMLElement>(".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<void> {
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";
}
}