Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ the reading of a tool result's JSON envelope and `tool denied:` / `tool failed:`
Copyright (c) 2026 Altan Sarisin. The MIT license text below applies. The session binding in
`server/muse-store.ts` is Ovis's own.

## oh-my-pi figure tokens

`src/lib/svgFigure.ts` adapts the figure token vocabulary and the token and root-tag resolution of
`prepareSvg` from [`can1357/oh-my-pi`](https://github.com/can1357/oh-my-pi),
`packages/tui/src/chat/svg-source.ts`, under MIT, Copyright (c) 2025 Mario Zechner,
Copyright (c) 2025-2026 Can Bölük, Copyright (c) 2026 Stencil Labs, Inc. The MIT license text
below applies. Mapping the tokens onto Ovis's theme roles and drawing the figure as an image are
Ovis's own.

## Theme palettes

The CSS theme colors adapt herdr's pinned palettes, with readability adjustments recorded in
Expand Down
5 changes: 5 additions & 0 deletions changelog.d/chat-svg-figures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Added
- Chat draws a ```svg fence as the figure it is, in the theme's colors, with the same enlarge view
as a Mermaid diagram. The figure is shown as an image, so its markup never runs in the page; a
fence still streaming, one that does not draw, one past 256 KiB, or one holding a script, an
event handler or a reference outside the figure stays a code block.
162 changes: 162 additions & 0 deletions scripts/chat-svg-figures-demo-regression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { chromium, type Browser } from "playwright-core";
import panes from "../site/demo/fixtures/panes.json";
import { appFaces } from "./app-faces.ts";
import { buildDemoApp } from "./demo-build.ts";
import { chromePath } from "./chrome-path.ts";

// A ```svg fence in a reply (#2441) is drawn as an <img> from a data: URL in the theme's colors,
// and one that could run or load something stays the code block it is. On the unmodified app over
// the demo's fixture transport, at 1280 and 390, Codex palette dark and light: the themed figure
// decodes as an image with its tokens resolved to the theme, the unsafe fence is a code block, its
// script never runs, nothing is fetched from outside the app, and the enlarge view shows the same
// image. The omp pane's conversation answers TURNS; all files and HTTP traffic stay in this
// disposable, loopback-only app and no herdr session is opened. Screenshots go to UI_EVIDENCE_DIR
// when it is set.
const app = mkdtempSync(join(tmpdir(), "herdr-chat-svg-figures-demo-"));
const evidence = process.env["UI_EVIDENCE_DIR"] || null;
if (evidence !== null) mkdirSync(evidence, { recursive: true });

const PANE = panes.omp;
const FIGURE = [
'<svg viewBox="0 0 360 120">',
' <rect x="4" y="4" width="352" height="112" rx="10" fill="var(--surface)" stroke="var(--border)"/>',
' <rect x="24" y="36" width="96" height="48" rx="6" fill="none" stroke="var(--accent)" stroke-width="2"/>',
' <text x="72" y="65" text-anchor="middle" fill="currentColor" font-size="14">client</text>',
' <path d="M120 60 H236" stroke="var(--muted)" stroke-width="2" marker-end="url(#arrow)"/>',
' <defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M0 0 L10 5 L0 10 z" fill="var(--muted)"/></marker></defs>',
' <rect x="240" y="36" width="96" height="48" rx="6" fill="none" stroke="var(--success)" stroke-width="2"/>',
' <text x="288" y="65" text-anchor="middle" fill="var(--c1)" font-size="14">server</text>',
"</svg>",
].join("\n");
const UNSAFE = [
'<svg viewBox="0 0 10 10"><script>window.__svgRan = "script"</script></svg>',
'<svg viewBox="0 0 10 10" onload="window.__svgRan = \'handler\'"></svg>',
'<svg viewBox="0 0 10 10"><image href="https://example.invalid/pixel.png" width="10" height="10"/></svg>',
'<svg xmlns:é="http://www.w3.org/2000/svg"><é:script>window.__svgRan = "prefixed"</é:script></svg>',
'<svg xmlns:é="http://www.w3.org/2000/svg"><é:style>@\\69mport "https://example.com/x.css";</é:style></svg>',
'<svg xmlns:é="http://www.w3.org/1999/xlink"><use href="#p"><set attributeName="é:href" to="https://example.com/x.svg#p"/></use></svg>',
'<svg><rect fill="u\\72l(https://example.com/x)"/></svg>',
'<svg><style>rect { fill: u<!--x-->rl(https://example.com/x); }</style></svg>',
'<svg><style>@im<!--x-->port "https://example.com/x.css";</style></svg>',
'<svg xml:base="https://example.com/"><use href="#p"/></svg>',
];
const REPLY = ["Here is the flow:", "", "```svg", FIGURE, "```", "", "And these must not draw:", "", ...UNSAFE.flatMap((fence) => ["```svg", fence, "```", ""]), "Done."].join("\n");
const TURNS = [
{ role: "user", ts: "2026-10-31T10:00:00.000Z", parts: [{ kind: "text", text: "Draw the flow" }] },
{ role: "assistant", ts: "2026-10-31T10:00:05.000Z", parts: [{ kind: "text", text: REPLY }] },
];

async function prepareDemo(): Promise<void> {
await buildDemoApp(app);
const index = join(app, "index.html");
const html = readFileSync(index, "utf8");
assert.match(html, /<script type="module"/);
// over the demo's own fetch: only this pane's conversation read is answered here
const override = `<script>(() => {
const demo = window.fetch;
const turns = ${JSON.stringify(TURNS).replaceAll("<", "\\u003c")};
window.fetch = (input, init) => {
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, location.href);
if (url.pathname === "/api/pane/conversation" && url.searchParams.get("pane_id") === ${JSON.stringify(PANE)}) {
const body = { source: "claude-transcript", history_id: "svg-figures", cursor: null, turns: url.searchParams.has("before") ? [] : turns };
return Promise.resolve(new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } }));
}
return demo(input, init);
};
})();</script>`;
writeFileSync(index, html.replace(/<script type="module"/, () => `<script src="./demo-transport.js"></script>\n ${override}\n <script type="module"`));
}

async function check(browser: Browser, origin: string, width: number, theme: "dark" | "light"): Promise<void> {
const phone = width <= 480;
const context = await browser.newContext(phone
? { viewport: { width, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true, locale: "en-US" }
: { viewport: { width, height: 900 }, locale: "en-US" });
try {
await context.addInitScript(([settings, pane]) => {
localStorage.setItem("ovis:settings", settings!);
localStorage.setItem(`ovis:view:${pane}`, "chat");
}, [JSON.stringify({ language: "en", alertInApp: false, theme, palette: "codex", darkTheme: "codex", lightTheme: "codex" }), PANE]);
const page = await context.newPage();
const errors: string[] = [];
const outside: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("request", (request) => { if (!request.url().startsWith(origin) && !request.url().startsWith("data:")) outside.push(request.url()); });
await page.goto(`${origin}/herdr-web-ui/demo/app/?pane=${encodeURIComponent(PANE)}`);
await page.locator(".conn-live").waitFor({ state: "attached" });
const scope = page.locator(`[data-layout-pane=${JSON.stringify(PANE)}].is-current`);
await scope.locator(".terminal-stack.is-chat").waitFor();
await scope.getByText("Done.", { exact: true }).waitFor();
await appFaces(page);

const figure = scope.locator(".markdown-mermaid img");
await figure.waitFor();
assert.equal(await figure.count(), 1, `${width} ${theme}: one fence is drawn as a figure`);
const drawn = await figure.evaluate(async (img: HTMLImageElement) => {
await img.decode();
return { src: img.src, width: img.naturalWidth, height: img.naturalHeight, text: getComputedStyle(document.documentElement).getPropertyValue("--text").trim(), accent: getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() };
});
assert.ok(drawn.src.startsWith("data:image/svg+xml"), `${width} ${theme}: the figure is a data: image`);
assert.deepEqual([drawn.width, drawn.height], [360, 120], `${width} ${theme}: the figure takes its viewBox size`);
const markup = decodeURIComponent(drawn.src.slice(drawn.src.indexOf(",") + 1));
assert.ok(!markup.includes("var("), `${width} ${theme}: every token is resolved`);
assert.ok(markup.includes(`stroke="${drawn.accent}"`), `${width} ${theme}: --accent is the theme's accent ${drawn.accent}`);
assert.ok(markup.includes(`color="${drawn.text}"`), `${width} ${theme}: currentColor is the theme's text ${drawn.text}`);

// each fence holds one unsafe part, so each is judged on its own
for (const marker of ["<script>", "onload=", "example.invalid", "é:script", "é:style", "é:href", "u\\72l", "u<!--x-->", "@im<!--x-->", "xml:base"]) {
assert.equal(await scope.locator("pre", { hasText: marker }).count(), 1, `${width} ${theme}: the fence with ${marker} stays a code block`);
}
assert.equal(await scope.locator(".markdown-mermaid img").count(), 1, `${width} ${theme}: only the safe fence is drawn`);
assert.equal(await page.evaluate(() => (window as unknown as { __svgRan?: string }).__svgRan), undefined, `${width} ${theme}: its script never ran`);
assert.deepEqual(outside, [], `${width} ${theme}: nothing is fetched from outside the app`);

await figure.scrollIntoViewIfNeeded();
if (evidence !== null) await page.screenshot({ path: join(evidence, `chat-svg-figures-codex-${theme}-${width}.png`) });
await scope.getByRole("button", { name: "Enlarge diagram" }).click();
const zoomed = page.locator(".mermaid-lightbox-canvas img");
await zoomed.waitFor();
assert.equal(await zoomed.getAttribute("src"), drawn.src, `${width} ${theme}: the enlarge view shows the same image`);
if (evidence !== null) await page.screenshot({ path: join(evidence, `chat-svg-figures-codex-${theme}-${width}-enlarged.png`) });
await page.keyboard.press("Escape");
await zoomed.waitFor({ state: "detached" });
// a theme change on a live page redraws the figure in the new colors
const other = theme === "dark" ? "light" : "dark";
await page.evaluate((next) => { document.documentElement.dataset.theme = next; }, other);
await page.waitForFunction((before) => document.querySelector(".markdown-mermaid img")?.getAttribute("src") !== before, drawn.src);
const redrawn = await figure.evaluate((img: HTMLImageElement) => ({ src: img.src, text: getComputedStyle(document.documentElement).getPropertyValue("--text").trim() }));
assert.notEqual(redrawn.text, drawn.text, `${width} ${theme}: the ${other} theme has another text color`);
assert.ok(decodeURIComponent(redrawn.src.slice(redrawn.src.indexOf(",") + 1)).includes(`color="${redrawn.text}"`), `${width} ${theme}: after the switch currentColor is ${redrawn.text}`);
assert.deepEqual(errors, []);
console.log(`PASS ${width}px ${theme}: figure ${drawn.width}x${drawn.height} in theme colors, each unsafe fence a code block, nothing ran or loaded, enlarge view, redrawn on a theme switch`);
} finally { await context.close(); }
}

try {
await prepareDemo();
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) {
const path = new URL(request.url).pathname;
if (!path.startsWith("/herdr-web-ui/demo/app/")) return new Response("not found", { status: 404 });
let file: string;
try { file = decodeURIComponent(path.slice("/herdr-web-ui/demo/app/".length)); }
catch { return new Response("bad path", { status: 400 }); }
if (!file || file.endsWith("/")) file += "index.html";
if (file.split("/").includes("..") || file.includes("\\")) return new Response("bad path", { status: 400 });
const body = Bun.file(join(app, file));
return (await body.exists()) ? new Response(body) : new Response("not found", { status: 404 });
} });
try {
const browser = await chromium.launch({ executablePath: chromePath(), headless: true, args: ["--no-sandbox"] });
try {
for (const width of [1280, 390]) {
for (const theme of ["dark", "light"] as const) await check(browser, `http://127.0.0.1:${server.port}`, width, theme);
}
} finally { await browser.close(); }
} finally { await server.stop(true); }
} finally {
rmSync(app, { recursive: true, force: true });
}
1 change: 1 addition & 0 deletions scripts/ci-browser-timings.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"scripts/droplet-demo-regression.ts": 15,
"scripts/mirror-fold-demo-regression.ts": 14,
"scripts/chat-list-markers-demo-regression.ts": 14,
"scripts/chat-svg-figures-demo-regression.ts": 14,
"scripts/held-rows-demo-regression.ts": 13,
"scripts/workspace-rows-regression.ts": 12,
"scripts/chat-fallback-browser-qa.ts": 12,
Expand Down
1 change: 1 addition & 0 deletions scripts/ci-browser.sh
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ run_script scripts/chat-local-send-follow-demo-regression.ts
run_script scripts/reply-comments-demo-regression.ts
run_script scripts/chat-tool-paths-demo-regression.ts
run_script scripts/chat-list-markers-demo-regression.ts
run_script scripts/chat-svg-figures-demo-regression.ts
run_script scripts/terminal-paste-insert-demo-regression.ts
run_script scripts/composer-fit-demo-regression.ts
run_script scripts/held-rows-demo-regression.ts
Expand Down
7 changes: 5 additions & 2 deletions server/omp-ask.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,11 @@ describe("omp Ask cards in the agent's own Markdown (#1644)", () => {
// omp draws a ```mermaid fence's code; the chat's Markdown would draw a diagram in its place
const multi = card(screen("18.13.2", "multi", 120));
expect(ompMarkdownPrompt(multi, open("18.13.2"))?.text_format).toBe("markdown");
const diagram = withArgs(open("18.13.2"), (args) => { args.questions[1]!.question = args.questions[1]!.question.replace("```sh", "```mermaid"); });
expect(ompMarkdownPrompt(multi, diagram)).toBe(multi);
// and a ```svg fence's, which the chat draws as a figure
for (const language of ["mermaid", "svg"]) {
const diagram = withArgs(open("18.13.2"), (args) => { args.questions[1]!.question = args.questions[1]!.question.replace("```sh", `\`\`\`${language}`); });
expect(ompMarkdownPrompt(multi, diagram)).toBe(multi);
}

// two options omp draws as different characters (or with different link addresses) that the card shows alike
const version = "18.12.6";
Expand Down
5 changes: 3 additions & 2 deletions server/omp-ask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,9 @@ function cardBlock(block: MarkdownBlock): string {
switch (block.type) {
case "paragraph": return block.lines.map((line) => cardInline(line, true)).join("\n");
case "heading": return `${block.level > 2 ? `${"#".repeat(block.level)} ` : ""}${cardInline(block.content, true)}`;
// the chat draws a ```mermaid fence as a diagram (src/components/Markdown.tsx), not the code omp shows (#2233)
case "code": return block.language === "mermaid" ? undrawn() : block.value;
// the chat draws a ```mermaid fence as a diagram and a ```svg one as a figure (src/components/Markdown.tsx),
// not the code omp shows (#2233)
case "code": return block.language === "mermaid" || block.language.toLowerCase() === "svg" ? undrawn() : block.value;
case "list": return block.items.map((item, index) => item.checked !== undefined ? undrawn()
: `${block.ordered ? `${(block.start ?? 1) + index}. ` : "- "}${cardInline(item.content, true)}\n${(item.blocks ?? []).map(cardBlock).join("\n")}`).join("\n");
default: return undrawn();
Expand Down
3 changes: 2 additions & 1 deletion src/components/ChatView.css
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,8 @@
cursor: zoom-in;
}

.markdown-mermaid svg {
.markdown-mermaid svg,
.markdown-mermaid img {
max-width: 100%;
max-height: 24rem;
height: auto;
Expand Down
46 changes: 45 additions & 1 deletion src/components/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { type MarkdownImagePreview } from "../lib/markdownImages.ts";
import { imageSlotStyle, standaloneImage } from "../lib/imageBox.ts";
import { formatBytes } from "../lib/bridgeProgress.ts";
import { MermaidLightbox } from "./MermaidLightbox.tsx";
import { svgFigurePalette, svgFigureUrl } from "../lib/svgFigure.ts";
import { copyText } from "../lib/clipboard.ts";
import { lazyChunk } from "../lib/lazyChunk.ts";
import type { SkillMatcher } from "../lib/skillMentions.ts";
Expand Down Expand Up @@ -109,6 +110,47 @@ function MermaidDiagram({ value }: { value: string }) {
);
}

/** Counts changes to <html>'s theme attributes (`data-theme`, `data-palette`, inline colors), so a figure redraws in the new colors. */
function useThemeRevision(): number {
const [revision, setRevision] = useState(0);
useEffect(() => {
const observer = new MutationObserver(() => setRevision((value) => value + 1));
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "data-palette", "style", "class"] });
Comment thread
devswha marked this conversation as resolved.
Resolved
return () => observer.disconnect();
}, []);
return revision;
}

/**
* A ```svg fence drawn as the figure it is (lib/svgFigure.ts), as an image so its markup never
* joins the page. Until it is a whole figure (a reply still streaming) or when it does not draw,
* it shows as the code block it is.
*/
function SvgFigure({ value }: { value: string }) {
const t = useT();
const revision = useThemeRevision();
const [zoomed, setZoomed] = useState(false);
const [failed, setFailed] = useState<string | null>(null);
// `revision` is not read: a change to it may be a new theme, whose colors the palette reads again.
// The viewport also writes <html>'s style (lib/viewport.ts): the figure is drawn again only when
// a color it reads changed, so a keyboard or resize does not reparse every figure.
const paletteKey = useMemo(() => JSON.stringify(svgFigurePalette((property) => getComputedStyle(document.documentElement).getPropertyValue(property))), [revision]);
const palette = useMemo(() => JSON.parse(paletteKey) as ReturnType<typeof svgFigurePalette>, [paletteKey]);
const src = useMemo(() => svgFigureUrl(value, palette), [value, palette]);
if (src === null || failed === src) return <CodeBlock language="svg" value={value} />;
return (
<div className="markdown-mermaid-frame">
<div className="markdown-mermaid" onClick={() => setZoomed(true)}>
<img src={src} alt={t("Diagram")} draggable={false} onError={() => setFailed(src)} />
</div>
<button type="button" className="icon-button markdown-mermaid-zoom" onClick={() => setZoomed(true)} aria-label={t("Enlarge diagram")} title={t("Enlarge diagram")}>
<Maximize2 aria-hidden="true" />
</button>
{zoomed && <MermaidLightbox src={src} onClose={() => setZoomed(false)} />}
</div>
);
}

/** Stats an image before it can load; null context keeps image references as links. */
type MarkdownImageResolver = (src: string) => Promise<MarkdownImagePreview | null>;
export const MarkdownImageContext = createContext<MarkdownImageResolver | null>(null);
Expand Down Expand Up @@ -366,7 +408,9 @@ function Blocks({ blocks }: { blocks: MarkdownBlock[] }) {
case "blockquote": return <blockquote key={key}><Blocks blocks={block.blocks} /></blockquote>;
case "code": return block.language === "mermaid"
? <MermaidDiagram key={key} value={block.value} />
: <CodeBlock key={key} language={block.language} value={block.value} />;
: block.language.toLowerCase() === "svg"
? <SvgFigure key={key} value={block.value} />
: <CodeBlock key={key} language={block.language} value={block.value} />;
case "math": return <MathExpression key={key} value={block.value} displayMode />;
case "hr": return <hr key={key} />;
case "table": return (
Expand Down
8 changes: 7 additions & 1 deletion src/components/MermaidLightbox.css
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@
transform-origin: 0 0;
}

.mermaid-lightbox-canvas svg {
.mermaid-lightbox-canvas svg,
.mermaid-lightbox-canvas img {
display: block;
width: 100%;
height: 100%;
Expand All @@ -75,3 +76,8 @@
top: calc(var(--space-3) + env(titlebar-area-y, 0px) + env(titlebar-area-height, 0px));
}
}

/* a ```svg figure (lib/svgFigure.ts) is an image: contain keeps its aspect ratio, as svg does */
.mermaid-lightbox-canvas img {
object-fit: contain;
}
Loading
Loading