Skip to content
Closed
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
26 changes: 15 additions & 11 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1678,25 +1678,33 @@ pre, code {
user-select: none;
}
.mermaid-zoom-viewport {
position: relative;
min-width: 0;
min-height: 0;
overflow: auto;
padding: 24px;
overflow: hidden;
background: var(--bg);
-webkit-overflow-scrolling: touch;
cursor: grab;
touch-action: none;
user-select: none;
overscroll-behavior: contain;
}
.mermaid-zoom-viewport.is-dragging {
cursor: grabbing;
}
.mermaid-zoom-canvas {
position: absolute;
top: 0;
left: 0;
padding: 12px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg-panel);
margin: 0 auto;
transition: width 0.15s ease;
transform-origin: 0 0;
}
/* The viewer pins the svg to its viewBox pixels (MermaidBlock.tsx) and scales
the wrapper, so its own max-width and 100% sizing must not apply. */
.mermaid-zoom-canvas svg {
display: block;
width: 100% !important;
height: auto;
max-width: none !important;
}

Expand All @@ -1708,10 +1716,6 @@ pre, code {
.mermaid-zoom-title {
font-size: 10px;
}

.mermaid-zoom-viewport {
padding: 12px;
}
}

/* File viewer toolbar */
Expand Down
214 changes: 195 additions & 19 deletions components/MermaidBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import SyntaxHighlighter from "react-syntax-highlighter/dist/esm/prism-light";
import { vs } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism";
Expand All @@ -17,9 +17,17 @@ interface MermaidBlockProps {
defaultPreview?: boolean;
}

const ZOOM_STEP = 0.25;
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 3;
// The viewer lays the diagram out at its natural pixel size and scales it with
// a transform, so the zooms below are relative to 100% of that size.
const ZOOM_STEP = 1.25;
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 8;
/** Space kept around the diagram when it is fitted to the viewport. */
const FIT_PADDING = 16;
/** A press that moved less than this (CSS px) is a click, not a pan. */
const DRAG_THRESHOLD = 3;
/** Wheel zoom strength: one notch (deltaY 100) is about a 22% step. */
const WHEEL_ZOOM_SPEED = 0.002;

export function downloadMermaidSvg(svg: SVGSVGElement): void {
// Mermaid's HTML serialization can leave void tags such as <br> unclosed.
Expand Down Expand Up @@ -148,25 +156,188 @@ export function MermaidBlock({ code, isStreaming, defaultPreview = false }: Merm
);
}

/** Zoom scale plus the translation of the diagram's top-left corner in the viewport. */
interface MermaidView {
scale: number;
x: number;
y: number;
}

interface MermaidDrag {
pointerId: number;
startX: number;
startY: number;
originX: number;
originY: number;
/** The press started on empty space, so releasing it without a drag closes the viewer. */
onBackground: boolean;
moved: boolean;
}

function clampZoom(scale: number): number {
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, scale));
}

/** Fit the diagram to the viewport width (never enlarging past its own size), centered. */
function fitMermaidView(
viewportWidth: number,
viewportHeight: number,
contentWidth: number,
contentHeight: number,
): MermaidView {
const availableWidth = Math.max(1, viewportWidth - FIT_PADDING * 2);
const scale = contentWidth > 0 ? clampZoom(Math.min(1, availableWidth / contentWidth)) : 1;
const width = contentWidth * scale;
const height = contentHeight * scale;
return {
scale,
x: (viewportWidth - width) / 2,
y: height + FIT_PADDING * 2 <= viewportHeight ? (viewportHeight - height) / 2 : FIT_PADDING,
};
}

function MermaidZoomDialog({ svg, onClose }: { svg: string; onClose: () => void }) {
const { t } = useI18n();
const dialogRef = useRef<HTMLDialogElement>(null);
const [zoom, setZoom] = useState(1);
const viewportRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<MermaidDrag | null>(null);
const [view, setView] = useState<MermaidView>({ scale: 1, x: 0, y: 0 });
const [dragging, setDragging] = useState(false);

useEffect(() => {
// Show the dialog, then pin the svg to the pixel size in its viewBox and fit
// it to the view. Both belong to one layout effect: a closed <dialog> has no
// layout box, so measuring before showModal() would only read zeros.
useLayoutEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const viewport = viewportRef.current;
const canvas = canvasRef.current;
if (!dialog || !viewport || !canvas) return;

const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
if (!dialog.open) dialog.showModal();

// Mermaid sizes its svg to the container (width="100%"), which leaves
// nothing to scale.
const svgElement = canvas.querySelector("svg");
const viewBox = svgElement?.viewBox?.baseVal;
const measured = svgElement?.getBoundingClientRect();
const width = viewBox?.width || measured?.width || 0;
const height = viewBox?.height || measured?.height || 0;
if (svgElement && width && height) {
svgElement.style.setProperty("width", `${width}px`, "important");
svgElement.style.setProperty("height", `${height}px`, "important");
svgElement.style.setProperty("max-width", "none", "important");
setView(fitMermaidView(viewport.clientWidth, viewport.clientHeight, canvas.offsetWidth, canvas.offsetHeight));
}

return () => {
document.body.style.overflow = previousOverflow;
if (dialog.open) dialog.close();
};
}, []);

/** Zoom by `factor`, keeping the diagram point under (pointerX, pointerY) in place. */
const zoomAt = useCallback((pointerX: number, pointerY: number, factor: number) => {
setView((current) => {
const scale = clampZoom(current.scale * factor);
if (scale === current.scale) return current;
const ratio = scale / current.scale;
return {
scale,
x: pointerX - (pointerX - current.x) * ratio,
y: pointerY - (pointerY - current.y) * ratio,
};
});
}, []);

const zoomFromCenter = (factor: number) => {
const viewport = viewportRef.current;
if (!viewport) return;
zoomAt(viewport.clientWidth / 2, viewport.clientHeight / 2, factor);
};

const fitToView = () => {
const viewport = viewportRef.current;
const canvas = canvasRef.current;
if (!viewport || !canvas) return;
setView(fitMermaidView(viewport.clientWidth, viewport.clientHeight, canvas.offsetWidth, canvas.offsetHeight));
};

// React attaches wheel listeners passively, so preventDefault (no page scroll
// behind the dialog) needs a listener of our own.
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;

const handleWheel = (event: WheelEvent) => {
event.preventDefault();
const rect = viewport.getBoundingClientRect();
const delta = event.deltaMode === 1
? event.deltaY * 16
: event.deltaMode === 2
? event.deltaY * rect.height
: event.deltaY;
if (!delta) return;
zoomAt(
event.clientX - rect.left,
event.clientY - rect.top,
Math.exp(-delta * WHEEL_ZOOM_SPEED),
);
};

viewport.addEventListener("wheel", handleWheel, { passive: false });
return () => viewport.removeEventListener("wheel", handleWheel);
}, [zoomAt]);

const endDrag = (event: { pointerId: number }) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return null;
dragRef.current = null;
setDragging(false);
const viewport = viewportRef.current;
if (viewport?.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId);
return drag;
};

const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current;
if (!viewport || event.button !== 0 || !event.isPrimary) return;
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: view.x,
originY: view.y,
onBackground: event.target === viewport,
moved: false,
};
viewport.setPointerCapture(event.pointerId);
};

const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
const dx = event.clientX - drag.startX;
const dy = event.clientY - drag.startY;
if (!drag.moved) {
if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
drag.moved = true;
setDragging(true);
}
setView((current) => ({ scale: current.scale, x: drag.originX + dx, y: drag.originY + dy }));
};

const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
const drag = endDrag(event);
if (drag && !drag.moved && drag.onBackground) onClose();
};

const handlePointerCancel = (event: React.PointerEvent<HTMLDivElement>) => {
endDrag(event);
};

return (
<dialog
ref={dialogRef}
Expand All @@ -189,20 +360,20 @@ function MermaidZoomDialog({ svg, onClose }: { svg: string; onClose: () => void
<div className="mermaid-zoom-stepper">
<button
type="button"
onClick={() => setZoom((value) => Math.max(ZOOM_MIN, value - ZOOM_STEP))}
disabled={zoom <= ZOOM_MIN}
onClick={() => zoomFromCenter(1 / ZOOM_STEP)}
disabled={view.scale <= ZOOM_MIN * 1.001}
title={t("i18n.zoomOut")}
aria-label={t("i18n.zoomOut")}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M5 12h14" />
</svg>
</button>
<span className="mermaid-zoom-value">{Math.round(zoom * 100)}%</span>
<span className="mermaid-zoom-value">{Math.round(view.scale * 100)}%</span>
<button
type="button"
onClick={() => setZoom((value) => Math.min(ZOOM_MAX, value + ZOOM_STEP))}
disabled={zoom >= ZOOM_MAX}
onClick={() => zoomFromCenter(ZOOM_STEP)}
disabled={view.scale >= ZOOM_MAX * 0.999}
title={t("i18n.zoomIn")}
aria-label={t("i18n.zoomIn")}
>
Expand All @@ -214,7 +385,7 @@ function MermaidZoomDialog({ svg, onClose }: { svg: string; onClose: () => void
<button
type="button"
className="mermaid-zoom-icon-button"
onClick={() => setZoom(1)}
onClick={fitToView}
title={t("i18n.fitToWidth")}
aria-label={t("i18n.fitToWidth")}
>
Expand All @@ -236,14 +407,19 @@ function MermaidZoomDialog({ svg, onClose }: { svg: string; onClose: () => void
</div>
</div>
<div
className="mermaid-zoom-viewport"
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
}}
ref={viewportRef}
className={["mermaid-zoom-viewport", dragging ? "is-dragging" : ""].filter(Boolean).join(" ")}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onLostPointerCapture={handlePointerCancel}
onDragStart={(event) => event.preventDefault()}
>
<div
ref={canvasRef}
className="mermaid-zoom-canvas"
style={{ width: `${zoom * 100}%` }}
style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }}
dangerouslySetInnerHTML={{ __html: svg }}
/>
</div>
Expand Down
6 changes: 6 additions & 0 deletions docs/agents/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,12 @@ Files: `lib/markdown.ts` (`markdownClobberPrefix`, `rehypeMatchClobberedIds`, `j
- The pieces are plain divs, not `memo`'d: React already leaves a div whose text is unchanged alone. A memo bailout keeps the old props, and each old piece is a V8 slice pinning the whole thinking string of the chunk it last changed in: streaming 2M Chinese characters retained 995 MB until the reply ended, 9 MB without memo (React 19 in headless Chrome).
- Expanding a finished block still lays out all of it once, as before (about 500 ms for that text at 4x). `content-visibility: auto` on the pieces would skip the ones off screen (70 ms to the first frame), but its placeholder heights, about half the real ones in the bench, change the scroll height as pieces come into view; it is not used.

## Mermaid viewer: zoom at the pointer, drag to pan (`MermaidZoomDialog` in `components/MermaidBlock.tsx`)
- Mermaid emits an svg sized to its container (`width="100%"`, a `max-width` style), so there is nothing to scale and no natural size to pan over. The viewer pins the svg to the pixels of its own `viewBox` (inline `width`/`height` marked important, `max-width: none`) and moves the wrapper with `transform: translate(x, y) scale(zoom)`, `transform-origin: 0 0`. 100% is therefore the diagram's own pixel size, and fit-to-width (the toolbar's reset) never enlarges past it.
- Anchored zoom is one line of math: the content point under the pointer is `(pointer - offset) / zoom`, so the next offset is `pointer - (pointer - offset) * (next / current)`. The wheel listener is added by hand in an effect with `{ passive: false }`: React's own `wheel` handler is passive, so `preventDefault()` (the page behind the modal must not scroll) would be ignored.
- Left-drag pans with pointer capture, so a drag that leaves the viewport keeps panning. The capture also makes every `click` land on the viewport, so background-click-to-close is decided in `pointerup`: the press must have started on the viewport itself (`event.target === viewport`) and moved less than 3 px — otherwise a pan that ends over empty space would dismiss the diagram.
- `showModal()` and the measuring/fitting belong to the same `useLayoutEffect`: a closed `<dialog>` has no layout box, so `clientWidth` and `offsetWidth` read zero before it opens, and a `useEffect` open would paint one frame of an unfitted diagram.

## Exported session HTML
- `/api/sessions/[id]/export` delegates to pi's export helper, then makes the generated HTML's recursive tree helpers iterative, so very deep linear sessions do not overflow the browser call stack.

Expand Down