diff --git a/apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.tsx b/apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.tsx
index eb77d0beb5e..88684727056 100644
--- a/apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.tsx
+++ b/apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.tsx
@@ -3,6 +3,8 @@
import mermaid from 'mermaid'
import { useEffect, useState } from 'react'
+import { Zoomable } from '@/components/ui/zoomable'
+import { copySvgAsPng } from '@/lib/svg-image'
import { cn } from '@/lib/utils'
import type { RichFenceProps } from './types'
@@ -88,10 +90,24 @@ export default function MermaidRenderer({ code, streaming }: RichFenceProps) {
return
}
+ // Click to open the diagram full-screen with pan/zoom + copy-as-PNG. The
+ // overlay keeps the diagram's natural width (capped to the viewport) so it
+ // renders before any zoom; the inline version stays capped at 33dvh.
return (
-
+ copySvgAsPng(svg)}
+ overlay={
+
+ }
+ >
+
+
)
}
diff --git a/apps/desktop/src/components/ui/use-zoom-pan.ts b/apps/desktop/src/components/ui/use-zoom-pan.ts
new file mode 100644
index 00000000000..86759297d64
--- /dev/null
+++ b/apps/desktop/src/components/ui/use-zoom-pan.ts
@@ -0,0 +1,97 @@
+import {
+ type CSSProperties,
+ type PointerEvent as ReactPointerEvent,
+ type WheelEvent as ReactWheelEvent,
+ useCallback,
+ useRef,
+ useState
+} from 'react'
+
+interface Transform {
+ scale: number
+ x: number
+ y: number
+}
+
+const MIN_SCALE = 0.25
+const MAX_SCALE = 8
+const WHEEL_STEP = 1.1
+const BUTTON_STEP = 1.25
+
+const clamp = (scale: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale))
+
+/**
+ * Headless pan/zoom transform. Wheel zooms toward the cursor, drag pans, buttons
+ * zoom toward centre. Returns the transform style plus the surface handlers, so
+ * any content (SVG, image, canvas) can be made pan/zoomable.
+ */
+export function useZoomPan() {
+ const [transform, setTransform] = useState({ scale: 1, x: 0, y: 0 })
+ const drag = useRef<{ x: number; y: number } | null>(null)
+ const [panning, setPanning] = useState(false)
+
+ // Zoom toward (cx, cy), measured from the surface centre, keeping that point fixed.
+ const zoomAt = useCallback((factor: number, cx = 0, cy = 0) => {
+ setTransform(prev => {
+ const scale = clamp(prev.scale * factor)
+ const k = scale / prev.scale
+
+ return { scale, x: cx - k * (cx - prev.x), y: cy - k * (cy - prev.y) }
+ })
+ }, [])
+
+ const onWheel = useCallback(
+ (event: ReactWheelEvent) => {
+ event.preventDefault()
+ const rect = event.currentTarget.getBoundingClientRect()
+ const cx = event.clientX - rect.left - rect.width / 2
+ const cy = event.clientY - rect.top - rect.height / 2
+
+ zoomAt(event.deltaY < 0 ? WHEEL_STEP : 1 / WHEEL_STEP, cx, cy)
+ },
+ [zoomAt]
+ )
+
+ const onPointerDown = useCallback((event: ReactPointerEvent) => {
+ event.currentTarget.setPointerCapture(event.pointerId)
+ setTransform(prev => {
+ drag.current = { x: event.clientX - prev.x, y: event.clientY - prev.y }
+
+ return prev
+ })
+ setPanning(true)
+ }, [])
+
+ const onPointerMove = useCallback((event: ReactPointerEvent) => {
+ if (!drag.current) {
+ return
+ }
+
+ const start = drag.current
+
+ setTransform(prev => ({ ...prev, x: event.clientX - start.x, y: event.clientY - start.y }))
+ }, [])
+
+ const endPan = useCallback(() => {
+ drag.current = null
+ setPanning(false)
+ }, [])
+
+ const reset = useCallback(() => setTransform({ scale: 1, x: 0, y: 0 }), [])
+ const zoomIn = useCallback(() => zoomAt(BUTTON_STEP), [zoomAt])
+ const zoomOut = useCallback(() => zoomAt(1 / BUTTON_STEP), [zoomAt])
+
+ const style: CSSProperties = {
+ transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`
+ }
+
+ return {
+ panning,
+ reset,
+ scale: transform.scale,
+ stageProps: { onPointerDown, onPointerLeave: endPan, onPointerMove, onPointerUp: endPan, onWheel },
+ style,
+ zoomIn,
+ zoomOut
+ }
+}
diff --git a/apps/desktop/src/components/ui/zoomable.tsx b/apps/desktop/src/components/ui/zoomable.tsx
new file mode 100644
index 00000000000..0c00c0217bf
--- /dev/null
+++ b/apps/desktop/src/components/ui/zoomable.tsx
@@ -0,0 +1,172 @@
+'use client'
+
+import { type ReactNode, useEffect, useState } from 'react'
+
+import { Dialog, DialogContent } from '@/components/ui/dialog'
+import { Check, Copy, Maximize, RefreshCw, X, ZoomIn, ZoomOut } from '@/lib/icons'
+import { cn } from '@/lib/utils'
+
+import { useZoomPan } from './use-zoom-pan'
+
+interface ZoomableProps {
+ /** Inline content; also the default full-view content. */
+ children: ReactNode
+ /** Full-view content, if it should differ from the inline version. */
+ overlay?: ReactNode
+ /** Copy/export action shown in the viewer toolbar. */
+ onCopy?: () => Promise | void
+ /** Accessible label for the expand affordance. */
+ label?: string
+ className?: string
+}
+
+/**
+ * Generic click-to-expand viewer: renders inline content with a hover "expand"
+ * affordance, then opens a full overlay where the content can be panned/zoomed
+ * (see useZoomPan) and optionally copied. Content-agnostic — wrap a diagram,
+ * image, or any node.
+ */
+export function Zoomable({ children, overlay, onCopy, label = 'Open full view', className }: ZoomableProps) {
+ const [open, setOpen] = useState(false)
+
+ return (
+ <>
+
+ {/* The whole content is the trigger — click anywhere to open, like an image. */}
+ setOpen(true)}
+ title={label}
+ type="button"
+ >
+ {children}
+
+
+
+
+
+ {open && (
+
+ {overlay ?? children}
+
+ )}
+ >
+ )
+}
+
+function ZoomPanViewer({
+ children,
+ onCopy,
+ onOpenChange,
+ open
+}: {
+ children: ReactNode
+ onCopy?: () => Promise | void
+ onOpenChange: (open: boolean) => void
+ open: boolean
+}) {
+ const { panning, reset, stageProps, style, zoomIn, zoomOut } = useZoomPan()
+
+ useEffect(() => {
+ if (open) {
+ reset()
+ }
+ }, [open, reset])
+
+ return (
+
+
+
+ onOpenChange(false)} onCopy={onCopy} reset={reset} zoomIn={zoomIn} zoomOut={zoomOut} />
+
+
+ )
+}
+
+function Toolbar({
+ onClose,
+ onCopy,
+ reset,
+ zoomIn,
+ zoomOut
+}: {
+ onClose: () => void
+ onCopy?: () => Promise | void
+ reset: () => void
+ zoomIn: () => void
+ zoomOut: () => void
+}) {
+ const [copied, setCopied] = useState(false)
+
+ const copy = async () => {
+ if (!onCopy) {
+ return
+ }
+
+ await onCopy()
+ setCopied(true)
+ window.setTimeout(() => setCopied(false), 1500)
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {onCopy && (
+ <>
+
+
void copy()}>
+ {copied ? : }
+
+ >
+ )}
+
+
+
+
+
+ )
+}
+
+function Divider() {
+ return
+}
+
+function ToolbarButton({ children, label, onClick }: { children: ReactNode; label: string; onClick: () => void }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts
index a1d4df8014f..ec30bc79c6f 100644
--- a/apps/desktop/src/lib/icons.ts
+++ b/apps/desktop/src/lib/icons.ts
@@ -56,6 +56,7 @@ import {
IconLock as Lock,
IconLogin as LogIn,
IconMail as Mail,
+ IconMaximize as Maximize,
IconMessageCircle as MessageCircle,
IconMessage2 as MessageSquareText,
IconMicrophone as Mic,
@@ -104,7 +105,9 @@ import {
IconX as X,
IconX as XIcon,
IconBolt as Zap,
- IconBoltFilled as ZapFilled
+ IconBoltFilled as ZapFilled,
+ IconZoomIn as ZoomIn,
+ IconZoomOut as ZoomOut
} from '@tabler/icons-react'
export {
@@ -165,6 +168,7 @@ export {
Lock,
LogIn,
Mail,
+ Maximize,
MessageCircle,
MessageSquareText,
Mic,
@@ -213,7 +217,9 @@ export {
X,
XIcon,
Zap,
- ZapFilled
+ ZapFilled,
+ ZoomIn,
+ ZoomOut
}
export type { Icon as IconComponent } from '@tabler/icons-react'
diff --git a/apps/desktop/src/lib/svg-image.ts b/apps/desktop/src/lib/svg-image.ts
new file mode 100644
index 00000000000..77c2b0756e4
--- /dev/null
+++ b/apps/desktop/src/lib/svg-image.ts
@@ -0,0 +1,56 @@
+// Rasterise an SVG string to PNG and copy it to the clipboard. Self-contained
+// SVGs only (inline styles) — mermaid output qualifies. Falls back to copying
+// the SVG markup as text where image clipboard writes aren't permitted.
+
+function svgSize(svg: string): { height: number; width: number } {
+ const el = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement
+ const width = parseFloat(el.getAttribute('width') || '')
+ const height = parseFloat(el.getAttribute('height') || '')
+
+ if (width && height) {
+ return { height, width }
+ }
+
+ const [, , vbW, vbH] = (el.getAttribute('viewBox') || '').split(/[\s,]+/).map(Number)
+
+ return vbW && vbH ? { height: vbH, width: vbW } : { height: 600, width: 800 }
+}
+
+export function svgToPngBlob(svg: string, scale = 2): Promise {
+ const { height, width } = svgSize(svg)
+
+ return new Promise((resolve, reject) => {
+ const image = new Image()
+
+ image.onload = () => {
+ const canvas = document.createElement('canvas')
+ canvas.width = Math.max(1, Math.round(width * scale))
+ canvas.height = Math.max(1, Math.round(height * scale))
+
+ const ctx = canvas.getContext('2d')
+
+ if (!ctx) {
+ reject(new Error('no 2d context'))
+
+ return
+ }
+
+ ctx.scale(scale, scale)
+ ctx.drawImage(image, 0, 0, width, height)
+ canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('toBlob failed'))), 'image/png')
+ }
+
+ image.onerror = () => reject(new Error('svg load failed'))
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
+ })
+}
+
+export async function copySvgAsPng(svg: string): Promise {
+ try {
+ const blob = await svgToPngBlob(svg)
+
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
+ } catch {
+ await navigator.clipboard.writeText(svg)
+ }
+}