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 = ``;
if (normalizedLang === "mermaid") {
- return `Mermaid source
${header}${codeBlock}
`;
+ return `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 = `
+
+ `;
+
+ 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("