diff --git a/ui/src/styles/chat/mermaid.css b/ui/src/styles/chat/mermaid.css index f24852e393f..9445873126b 100644 --- a/ui/src/styles/chat/mermaid.css +++ b/ui/src/styles/chat/mermaid.css @@ -9,6 +9,7 @@ .chat-text :where(.mermaid-block__render) { overflow-x: auto; padding: 12px; + cursor: zoom-in; } .chat-text :where(.mermaid-block__render svg) { @@ -17,6 +18,11 @@ height: auto; } +.chat-text :where(.mermaid-block__render:focus-visible) { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + .chat-text :where(.mermaid-block__source) { border-top: 1px solid var(--border); } @@ -33,3 +39,55 @@ .chat-text :where(.mermaid-block__source > summary::-webkit-details-marker) { display: none; } + +.mermaid-block__dialog { + width: min(92vw, 1200px); + max-width: 1200px; + max-height: 88vh; + padding: 0; + border: 1px solid var(--border); + border-radius: calc(var(--radius-lg) + 4px); + background: var(--panel); + color: var(--text); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35); +} + +.mermaid-block__dialog::backdrop { + background: rgba(15, 23, 42, 0.55); + backdrop-filter: blur(4px); +} + +.mermaid-block__dialog-panel { + display: flex; + flex-direction: column; + max-height: 88vh; +} + +.mermaid-block__dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); +} + +.mermaid-block__dialog-title { + font-size: 14px; + font-weight: 600; +} + +.mermaid-block__dialog-body { + overflow: auto; + padding: 20px; + background: color-mix(in srgb, var(--secondary) 65%, transparent); +} + +.mermaid-block__dialog-body svg { + display: block; + min-width: min(960px, 100%); + width: max-content; + max-width: none; + height: auto; + margin: 0 auto; +} diff --git a/ui/src/ui/code-block-copy.test.ts b/ui/src/ui/code-block-copy.test.ts new file mode 100644 index 00000000000..297b3d8853c --- /dev/null +++ b/ui/src/ui/code-block-copy.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { handleCodeBlockCopyClick } from "./code-block-copy.ts"; + +describe("handleCodeBlockCopyClick", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + it("copies code from a clicked code-block button", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + document.body.innerHTML = ` + + `; + + const button = document.querySelector(".code-block-copy"); + expect(button).not.toBeNull(); + + const event = new MouseEvent("click", { bubbles: true }); + button?.dispatchEvent(event); + handleCodeBlockCopyClick(event); + + await Promise.resolve(); + + expect(writeText).toHaveBeenCalledWith("console.log('copied')"); + expect(button?.classList.contains("copied")).toBe(true); + }); +}); diff --git a/ui/src/ui/code-block-copy.ts b/ui/src/ui/code-block-copy.ts new file mode 100644 index 00000000000..99c0b7aa093 --- /dev/null +++ b/ui/src/ui/code-block-copy.ts @@ -0,0 +1,26 @@ +const COPIED_FOR_MS = 1500; + +export function handleCodeBlockCopyClick(e: Event): void { + const btn = (e.target as HTMLElement | null)?.closest(".code-block-copy"); + if (!btn) { + return; + } + + const code = btn.dataset.code ?? ""; + if (!code) { + return; + } + + void navigator.clipboard.writeText(code).then( + () => { + btn.classList.add("copied"); + window.setTimeout(() => { + if (!btn.isConnected) { + return; + } + btn.classList.remove("copied"); + }, COPIED_FOR_MS); + }, + () => {}, + ); +} diff --git a/ui/src/ui/markdown.test.ts b/ui/src/ui/markdown.test.ts index fb626009065..86ee6503717 100644 --- a/ui/src/ui/markdown.test.ts +++ b/ui/src/ui/markdown.test.ts @@ -36,8 +36,10 @@ describe("toSanitizedMarkdownHtml", () => { ); expect(html).toContain('class="mermaid-block"'); expect(html).toContain('class="mermaid-block__render"'); + expect(html).toContain('class="mermaid-block__dialog"'); expect(html).toContain('class="language-mermaid"'); expect(html).toContain("Mermaid source"); + expect(html).toContain("Click to enlarge"); }); it("flattens remote markdown images into alt text", () => { diff --git a/ui/src/ui/markdown.ts b/ui/src/ui/markdown.ts index 12e813c7204..a1ca49e723d 100644 --- a/ui/src/ui/markdown.ts +++ b/ui/src/ui/markdown.ts @@ -11,6 +11,7 @@ const allowedTags = [ "code", "del", "details", + "dialog", "div", "em", "h1", @@ -48,7 +49,9 @@ const allowedAttrs = [ "data-code", "type", "aria-label", + "aria-modal", "role", + "tabindex", ]; const sanitizeOptions = { ALLOWED_TAGS: allowedTags, @@ -199,7 +202,7 @@ htmlEscapeRenderer.code = ({ const header = `
${langLabel}${copyBtn}
`; if (normalizedLang === "mermaid") { - return `
Mermaid source
${header}${codeBlock}
`; + return `
Mermaid diagram
Mermaid source
${header}${codeBlock}
`; } const trimmed = text.trim(); diff --git a/ui/src/ui/mermaid.test.ts b/ui/src/ui/mermaid.test.ts new file mode 100644 index 00000000000..5f978dc0134 --- /dev/null +++ b/ui/src/ui/mermaid.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { installMermaidInteractions } from "./mermaid.ts"; + +describe("installMermaidInteractions", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + it("opens the mermaid preview dialog when the rendered diagram is clicked", () => { + installMermaidInteractions(document); + + document.body.innerHTML = ` +
+
+ Diagram +
+ +
+
+
+ `; + + const dialog = document.querySelector(".mermaid-block__dialog"); + const showModal = vi.fn(); + Object.defineProperty(dialog!, "showModal", { + configurable: true, + value: showModal, + }); + + document.querySelector(".mermaid-block__render")?.click(); + + expect(showModal).toHaveBeenCalledOnce(); + expect(dialog?.querySelector(".mermaid-block__dialog-body")?.innerHTML).toContain(" | null = null; let renderScheduled = false; let renderCounter = 0; +const interactionDocs = new WeakSet(); async function loadMermaidApi(): Promise { if (!mermaidApiPromise) { @@ -17,6 +18,10 @@ async function loadMermaidApi(): Promise { api.initialize({ startOnLoad: false, securityLevel: "strict", + // Keep labels as native SVG text instead of foreignObject-backed HTML. + // The rendered SVG is sanitized before insertion, and HTML labels can + // lose their contents during that pass, leaving empty shapes behind. + htmlLabels: false, }); return api; }) @@ -30,7 +35,90 @@ async function loadMermaidApi(): Promise { return mermaidApiPromise; } +export function installMermaidInteractions(root: ParentNode = document): void { + const doc = root instanceof Document ? root : root.ownerDocument; + if (!doc || interactionDocs.has(doc)) { + return; + } + interactionDocs.add(doc); + + doc.addEventListener("click", (e) => { + const target = e.target; + if (!(target instanceof Element)) { + return; + } + + const closeButton = target.closest(".mermaid-block__dialog-close"); + if (closeButton) { + closeMermaidDialog(closeButton.closest(".mermaid-block__dialog")); + return; + } + + const dialog = target.closest(".mermaid-block__dialog"); + if (dialog && target === dialog) { + closeMermaidDialog(dialog); + return; + } + + const renderTarget = target.closest(".mermaid-block__render"); + if (!renderTarget) { + return; + } + + const block = renderTarget.closest(".mermaid-block"); + if (!block || block.dataset.mermaidStatus !== "ready") { + return; + } + + openMermaidDialog(block); + }); + + doc.addEventListener("keydown", (e) => { + const target = e.target; + if (!(target instanceof HTMLElement) || !target.classList.contains("mermaid-block__render")) { + return; + } + if (e.key !== "Enter" && e.key !== " ") { + return; + } + const block = target.closest(".mermaid-block"); + if (!block || block.dataset.mermaidStatus !== "ready") { + return; + } + e.preventDefault(); + openMermaidDialog(block); + }); +} + +function openMermaidDialog(block: HTMLElement): void { + const dialog = block.querySelector(".mermaid-block__dialog"); + const body = block.querySelector(".mermaid-block__dialog-body"); + const renderTarget = block.querySelector(".mermaid-block__render"); + if (!dialog || !body || !renderTarget) { + return; + } + + body.innerHTML = renderTarget.innerHTML; + if (typeof dialog.showModal === "function") { + dialog.showModal(); + return; + } + dialog.setAttribute("open", ""); +} + +function closeMermaidDialog(dialog: HTMLDialogElement | null): void { + if (!dialog) { + return; + } + if (typeof dialog.close === "function") { + dialog.close(); + return; + } + dialog.removeAttribute("open"); +} + export function scheduleMermaidRender(root: ParentNode = document): void { + installMermaidInteractions(root); if (renderScheduled) { return; } diff --git a/ui/src/ui/views/agents-panels-status-files.ts b/ui/src/ui/views/agents-panels-status-files.ts index bff74f0523b..360e8c3bcb8 100644 --- a/ui/src/ui/views/agents-panels-status-files.ts +++ b/ui/src/ui/views/agents-panels-status-files.ts @@ -1,5 +1,6 @@ import { html, nothing } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { handleCodeBlockCopyClick } from "../code-block-copy.ts"; import { formatRelativeTimestamp } from "../format.ts"; import { icons } from "../icons.ts"; import { toSanitizedMarkdownHtml } from "../markdown.ts"; @@ -485,7 +486,10 @@ export function renderAgentFiles(params: { }} >${icons.x} Close - diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 88a712706f0..31ccfb5c997 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -26,6 +26,7 @@ import { type SlashCommandDef, } from "../chat/slash-commands.ts"; import { isSttSupported, startStt, stopStt } from "../chat/speech.ts"; +import { handleCodeBlockCopyClick } from "../code-block-copy.ts"; import { icons } from "../icons.ts"; import { detectTextDirection } from "../text-direction.ts"; import type { GatewaySessionRow, SessionsListResult } from "../types.ts"; @@ -834,21 +835,6 @@ export function renderChat(props: ChatProps) { const splitRatio = props.splitRatio ?? 0.6; const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar); - const handleCodeBlockCopy = (e: Event) => { - const btn = (e.target as HTMLElement).closest(".code-block-copy"); - if (!btn) { - return; - } - const code = (btn as HTMLElement).dataset.code ?? ""; - navigator.clipboard.writeText(code).then( - () => { - btn.classList.add("copied"); - setTimeout(() => btn.classList.remove("copied"), 1500); - }, - () => {}, - ); - }; - const chatItems = buildChatItems(props); const isEmpty = chatItems.length === 0 && !props.loading; @@ -858,7 +844,7 @@ export function renderChat(props: ChatProps) { role="log" aria-live="polite" @scroll=${props.onChatScroll} - @click=${handleCodeBlockCopy} + @click=${handleCodeBlockCopyClick} >
${ diff --git a/ui/src/ui/views/markdown-sidebar.ts b/ui/src/ui/views/markdown-sidebar.ts index 006bd6ac44b..6c9805a6f25 100644 --- a/ui/src/ui/views/markdown-sidebar.ts +++ b/ui/src/ui/views/markdown-sidebar.ts @@ -1,5 +1,6 @@ import { html } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { handleCodeBlockCopyClick } from "../code-block-copy.ts"; import { icons } from "../icons.ts"; import { toSanitizedMarkdownHtml } from "../markdown.ts"; @@ -19,7 +20,7 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) { ${icons.x}
-