control-ui: address Mermaid review feedback
This commit is contained in:
parent
6aee80074c
commit
e8d583efb3
@ -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();
|
||||
});
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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("");
|
||||
expect(html).not.toContain("<img");
|
||||
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user