diff --git a/application/single_app/config.py b/application/single_app/config.py
index 3e0d90142..0eb7affbc 100644
--- a/application/single_app/config.py
+++ b/application/single_app/config.py
@@ -97,7 +97,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
-VERSION = "0.261.036"
+VERSION = "0.261.037"
IS_DEVELOPMENT = is_development_env_enabled()
SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
diff --git a/application/single_app/functions_diagram_operations.py b/application/single_app/functions_diagram_operations.py
index 809044a6b..2b7a66094 100644
--- a/application/single_app/functions_diagram_operations.py
+++ b/application/single_app/functions_diagram_operations.py
@@ -88,11 +88,18 @@ def build_diagram_guidance_message():
Keep the source valid so it renders on the first attempt:
- Give every node a quoted label, for example `app["Simple Chat App Service"]`. Unquoted parentheses, braces, angle brackets, colons, `#`, and quotes inside a label break the parser.
+- Never use `end`, `graph`, `class`, `style`, `subgraph`, or `click` as a node id: they are reserved words and the diagram will not parse. Write `end_state` or `graph_node` instead.
+- Close every `subgraph` with a lowercase `end` on its own line. `End` and `END` are not accepted.
- Write one statement per line, and use `%%` for comments.
- Use ` ` inside a quoted label for a line break; do not use raw newlines.
- Do not use `click`, `style` with URLs, or any directive that links or navigates. They are stripped before rendering.
- Prefer one clear diagram over several near-duplicates, place it directly after the prose it illustrates, and add a short sentence introducing it.
+Keep it readable. A diagram is a picture, not a transcript:
+- Keep each node label to a short phrase, roughly a handful of words. Split detail across several connected nodes instead of writing one node with a dozen ` ` lines in it, which renders as a tall column of text nobody can take in.
+- When the user pastes text or ASCII art to be turned into a diagram, translate the structure and summarise the detail. Do not carry placeholders such as ``, literal `{{}}`, or quoted fragments into labels; describe them in words, or leave them to the prose around the diagram.
+- Aim for something that fits on a screen. Beyond roughly twenty nodes, split the answer into more than one diagram, each with its own heading.
+
A diagram is not always the right answer. When the content is narrative, numeric, or a simple list, prose, a table, or a chart is better. Base every node and edge on the source material or the user's own description, and never invent components, systems, or relationships to fill out a picture.
Use a diagram, not a generated image, for structural content such as flows, architectures, sequences, and relationships: Mermaid output stays selectable, accessible, and editable. Reserve image generation for illustrative or pictorial visuals. Use inline chart blocks, not Mermaid, when the answer is a plot of numeric or categorical data."""
diff --git a/application/single_app/functions_message_visual_styles.py b/application/single_app/functions_message_visual_styles.py
index 5f99ca8b0..f094dc654 100644
--- a/application/single_app/functions_message_visual_styles.py
+++ b/application/single_app/functions_message_visual_styles.py
@@ -17,8 +17,14 @@
mermaid's theme configuration in a browser, so colours are reduced to `#rrggbb` and nothing
else is stored. Sizes are capped so a message document cannot be grown without bound by
repeated requests.
+
+An entry also carries the height someone dragged the block to. That is stored and cleared
+independently of the colours, because the two are separate choices: resetting a diagram's
+colours should not silently snap it back to its automatic height, and resizing a diagram
+should not stop it following the reader's default palette.
"""
+import math
import re
# Fence languages a style may be saved against. Matches VISUAL_STYLE_KINDS in
@@ -44,6 +50,15 @@
# Total stored entries across every kind, which bounds the size of the stored map.
MAX_STORED_ENTRIES = 100
+# Stored block height in pixels. Matches MIN_STAGE_HEIGHT and MAX_STAGE_HEIGHT in
+# application/v2_ui/src/components/chat/DiagramStage.tsx.
+MIN_BLOCK_HEIGHT = 140
+MAX_BLOCK_HEIGHT = 2000
+
+# Sentinel meaning "the caller said nothing about the height", which is different from the
+# caller asking for the stored height to be removed.
+UNSET = object()
+
HEX_COLOR_PATTERN = re.compile(r'^#[0-9a-fA-F]{6}$')
# Long enough for the 32-bit hex fingerprint the client sends, with room to spare.
@@ -94,6 +109,26 @@ def validate_source_hash(value):
return candidate
+def validate_block_height(value):
+ """Return a storable block height in pixels, or None to clear a stored one.
+
+ Clamped rather than rejected when out of range. The value comes from a drag, so a request
+ a few pixels past the limit is a reader holding the mouse down, not a client misbehaving,
+ and refusing it would lose a change they clearly meant to make.
+
+ Non-finite values are refused rather than clamped. ``json.loads`` accepts the bare
+ ``Infinity`` and ``NaN`` tokens, and ``round(float('inf'))`` raises ``OverflowError``, which
+ would escape the caller's ``VisualStyleError`` handling and turn a bad request into a 500.
+ """
+ if value is None:
+ return None
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ raise VisualStyleError('Height must be a number')
+ if not math.isfinite(value):
+ raise VisualStyleError('Height must be a finite number')
+ return int(min(MAX_BLOCK_HEIGHT, max(MIN_BLOCK_HEIGHT, round(value))))
+
+
def sanitize_visual_style(value):
"""Return a storable style dict, rejecting anything that is not one.
@@ -160,30 +195,63 @@ def count_entries(styles):
return sum(len(entries) for entries in styles.values())
-def apply_visual_style(message_doc, block_kind, block_index, style, source_hash=''):
- """Store, replace or remove one block's colours, returning the resulting map.
+def apply_visual_style(
+ message_doc,
+ block_kind,
+ block_index,
+ style,
+ source_hash='',
+ height=UNSET,
+):
+ """Store, replace or remove one block's colours and height, returning the resulting map.
- ``style`` of None removes the entry, which is different from storing a style that happens
+ ``style`` of None removes the colours, which is different from storing a style that happens
to equal the reader's current default: the default can change later, and a removed entry
should follow it.
+
+ ``height`` left at ``UNSET`` keeps whatever is stored, so a colour change does not disturb a
+ size someone chose. ``None`` clears it. The entry itself only disappears once it holds
+ neither colours nor a height.
"""
kind = validate_block_kind(block_kind)
index = validate_block_index(block_index)
fingerprint = validate_source_hash(source_hash)
+ resolved_height = UNSET if height is UNSET else validate_block_height(height)
styles = read_visual_styles(message_doc)
entries = dict(styles.get(kind) or {})
+ existing = entries.get(str(index))
+ existing = existing if isinstance(existing, dict) else {}
+
+ # A stored entry whose fingerprint no longer matches describes different content, and the
+ # client already ignores it. Carrying its height forward would resurrect it and stamp it
+ # with the new fingerprint, making a size chosen for a block that no longer exists at this
+ # position authoritative for the one that does.
+ existing_hash = existing.get('source_hash')
+ if isinstance(existing_hash, str) and existing_hash and fingerprint and existing_hash != fingerprint:
+ existing = {}
if style is None:
- entries.pop(str(index), None)
+ entry = {}
else:
- sanitized = sanitize_visual_style(style)
+ entry = sanitize_visual_style(style)
+
+ if resolved_height is UNSET:
+ kept_height = existing.get('height')
+ if isinstance(kept_height, int) and not isinstance(kept_height, bool):
+ entry['height'] = kept_height
+ elif resolved_height is not None:
+ entry['height'] = resolved_height
+
+ if entry:
if fingerprint:
- sanitized['source_hash'] = fingerprint
+ entry['source_hash'] = fingerprint
is_new = str(index) not in entries
if is_new and count_entries(styles) >= MAX_STORED_ENTRIES:
raise VisualStyleError('Too many styled blocks in this message')
- entries[str(index)] = sanitized
+ entries[str(index)] = entry
+ else:
+ entries.pop(str(index), None)
if entries:
styles[kind] = entries
diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py
index 9d667ee58..1241c78de 100644
--- a/application/single_app/route_backend_chats.py
+++ b/application/single_app/route_backend_chats.py
@@ -235,6 +235,7 @@
resolve_mask_display_name,
)
from functions_message_visual_styles import (
+ UNSET as VISUAL_STYLE_HEIGHT_UNSET,
VisualStyleError,
apply_visual_style,
)
@@ -24902,13 +24903,16 @@ def mask_message_api(message_id):
@login_required
@user_required
def set_message_visual_style_api(message_id):
- """Save or clear the colours chosen for one diagram or chart inside a message.
+ """Save or clear the colours and height chosen for one diagram or chart in a message.
A reply can contain several styleable blocks, so the request identifies one of them by
its position among blocks of the same kind. Recolouring one block therefore leaves the
others untouched, which is the whole point of storing this per block rather than per
message.
+ ``height`` is optional and independent of ``style``: omitting it keeps whatever size the
+ block was left at, so changing colours never resets a diagram someone resized.
+
Unlike the classic client's chart colour editor, nothing here rewrites the message
content: the payload the model produced stays exactly as it was written, and the
colours live beside it in metadata.
@@ -24956,6 +24960,9 @@ def set_message_visual_style_api(message_id):
data.get('block_index'),
data.get('style'),
data.get('source_hash') or '',
+ # A body that never mentions the height leaves the stored one alone; one
+ # that sends null is asking for it to be cleared.
+ data.get('height') if 'height' in data else VISUAL_STYLE_HEIGHT_UNSET,
)
except VisualStyleError as ex:
debug_print(f'[VISUAL_STYLE] Invalid request: {ex}')
diff --git a/application/v2_ui/src/components/chat/DiagramStage.tsx b/application/v2_ui/src/components/chat/DiagramStage.tsx
new file mode 100644
index 000000000..de8d863ba
--- /dev/null
+++ b/application/v2_ui/src/components/chat/DiagramStage.tsx
@@ -0,0 +1,308 @@
+// DiagramStage.tsx
+// The scrolling, resizable area a rendered diagram is drawn in.
+//
+// Split out of MermaidDiagram so that the sizing behaviour — which is most of what makes a
+// diagram readable — is separable from rendering it. This file deliberately contains no HTML
+// sink: the markup it shows is passed in already sanitized, and every place diagram markup is
+// written into the DOM stays in MermaidDiagram.tsx, so the reviewed sanitizer boundary remains
+// a single file.
+//
+// Two numbers drive everything here:
+//
+// - the diagram's natural size, read off the SVG mermaid emitted. Mermaid renders with
+// `useMaxWidth: true`, which produces `width="100%"`, no height attribute and
+// `style="max-width: Npx"`. A percentage width contributes nothing to intrinsic sizing, so
+// a diagram in the shrink-to-fit assistant bubble collapsed the bubble to the width of its
+// own toolbar and then scaled itself down to match. Reading N back and applying it as a
+// definite width is what stops that.
+//
+// - the stage height, which is capped. A flowchart with a few hundred edges renders tens of
+// thousands of pixels tall; left in the message list, the browser re-rasterizes it on every
+// scroll frame and the thread becomes unusable.
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+/** Smallest stage a reader can drag to. Below this the diagram is not worth showing. */
+export const MIN_STAGE_HEIGHT = 140;
+
+/** Largest stage a reader can drag to, and the ceiling on a persisted height. */
+export const MAX_STAGE_HEIGHT = 2000;
+
+/**
+ * Tallest a diagram is drawn at before its stage starts scrolling.
+ *
+ * Roughly two thirds of a laptop viewport: tall enough that most diagrams are shown whole,
+ * short enough that a very long one does not push the rest of the reply off the screen or
+ * leave a huge SVG in the scroll container.
+ */
+export const DEFAULT_MAX_STAGE_HEIGHT = 520;
+
+/** Narrowest the panel goes, so the toolbar never wraps into a column. */
+export const MIN_FIGURE_WIDTH = 320;
+
+/**
+ * The stage's own padding, top and bottom.
+ *
+ * Kept as a number because the automatic height has to account for it: the height is set on the
+ * padding box, so a stage sized to the diagram alone is short by exactly this much and shows a
+ * scrollbar on a diagram that actually fits.
+ */
+const STAGE_PADDING = 24;
+
+/** Zoom bounds, as a multiple of the scale that fits the diagram to the stage width. */
+export const MIN_ZOOM = 0.4;
+export const MAX_ZOOM = 4;
+
+/** Multiplier per press of the zoom buttons. */
+export const ZOOM_STEP = 1.25;
+
+/** How much one arrow-key press moves the resize handle. */
+const RESIZE_KEY_STEP = 40;
+
+export interface DiagramSize {
+ width: number;
+ height: number;
+}
+
+export function clampStageHeight(value: number): number {
+ return Math.min(MAX_STAGE_HEIGHT, Math.max(MIN_STAGE_HEIGHT, Math.round(value)));
+}
+
+export function clampZoom(value: number): number {
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value));
+}
+
+/**
+ * Read a rendered diagram's natural size out of the SVG markup.
+ *
+ * `max-width` is what mermaid writes when `useMaxWidth` is on and is the authoritative natural
+ * width; the viewBox is the fallback and the only source of the height, because mermaid writes
+ * no height attribute in that mode. Returns null rather than a guess when neither is present,
+ * so the caller can fall back to letting the browser size the diagram as it did before.
+ */
+export function readDiagramSize(svg: string): DiagramSize | null {
+ const viewBox = /viewBox="([^"]*)"/.exec(svg);
+ const parts = viewBox
+ ? viewBox[1].trim().split(/[\s,]+/).map(Number)
+ : [];
+ const boxWidth = parts.length === 4 && Number.isFinite(parts[2]) ? parts[2] : 0;
+ const boxHeight = parts.length === 4 && Number.isFinite(parts[3]) ? parts[3] : 0;
+
+ const maxWidth = /max-width:\s*([0-9.]+)px/.exec(svg);
+ const declaredWidth = maxWidth ? Number(maxWidth[1]) : 0;
+
+ const width = Number.isFinite(declaredWidth) && declaredWidth > 0 ? declaredWidth : boxWidth;
+ if (!(width > 0) || !(boxHeight > 0)) {
+ return null;
+ }
+
+ // The height that goes with `width`, since the two can differ when mermaid's declared
+ // max-width has been rounded away from the viewBox.
+ const height = boxWidth > 0 ? (boxHeight * width) / boxWidth : boxHeight;
+ return { width: Math.round(width), height: Math.round(height) };
+}
+
+/**
+ * The stage height to use when nobody has chosen one.
+ *
+ * The diagram is fitted to the panel width first, because that is how it will actually be
+ * drawn, and the resulting height is then capped. A wide, short diagram therefore gets a short
+ * stage rather than an empty one, and a tall diagram gets a scrolling stage rather than a
+ * thousand-pixel block in the thread.
+ *
+ * `panelWidth` is the stage's content width, so the padding is added back afterwards.
+ */
+export function defaultStageHeight(size: DiagramSize | null, panelWidth: number): number {
+ if (!size || panelWidth <= 0) {
+ return MIN_STAGE_HEIGHT;
+ }
+ const fitted = size.height * Math.min(1, panelWidth / size.width);
+ return clampStageHeight(Math.min(fitted + STAGE_PADDING, DEFAULT_MAX_STAGE_HEIGHT));
+}
+
+/**
+ * A grab bar for resizing the stage.
+ *
+ * A separate control rather than CSS `resize`, which cannot be operated from the keyboard and
+ * offers no way back to the automatic height. Exposed as a slider because that is what it is:
+ * a single value with a range, a current position and a meaningful reset.
+ */
+function ResizeHandle({
+ height,
+ onResize,
+ onReset,
+}: {
+ height: number;
+ onResize: (next: number) => void;
+ onReset: () => void;
+}) {
+ const dragRef = useRef<{ startY: number; startHeight: number } | null>(null);
+
+ const onPointerDown = (event: React.PointerEvent) => {
+ event.preventDefault();
+ dragRef.current = { startY: event.clientY, startHeight: height };
+ event.currentTarget.setPointerCapture(event.pointerId);
+ };
+
+ const onPointerMove = (event: React.PointerEvent) => {
+ const drag = dragRef.current;
+ if (!drag) {
+ return;
+ }
+ onResize(clampStageHeight(drag.startHeight + (event.clientY - drag.startY)));
+ };
+
+ const endDrag = (event: React.PointerEvent) => {
+ if (!dragRef.current) {
+ return;
+ }
+ dragRef.current = null;
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ }
+ };
+
+ return (
+
+ );
+}
+
+/**
+ * The area a diagram is drawn in, with its own scrolling and a handle to resize it.
+ *
+ * `children` is the already-rendered diagram. This component owns only how much room it gets
+ * and how far it is scaled; it never touches the markup.
+ */
+export function DiagramStage({
+ size,
+ height,
+ zoom,
+ onResize,
+ onResetHeight,
+ onPanelWidth,
+ background,
+ children,
+}: {
+ size: DiagramSize | null;
+ height: number;
+ /** Multiplier applied on top of fitting the diagram to the stage width. */
+ zoom: number;
+ onResize: (next: number) => void;
+ onResetHeight: () => void;
+ /** Reports the stage's laid-out width, which the fit scale is computed from. */
+ onPanelWidth: (width: number) => void;
+ background?: string;
+ children: React.ReactNode;
+}) {
+ const stageRef = useRef(null);
+ const [panelWidth, setPanelWidth] = useState(0);
+
+ const report = useCallback(
+ (width: number) => {
+ setPanelWidth(width);
+ onPanelWidth(width);
+ },
+ [onPanelWidth],
+ );
+
+ // The stage width is what the fit scale divides by, and it changes when the window is
+ // resized, the sidebar is toggled or the reading-width preference is changed.
+ useEffect(() => {
+ const element = stageRef.current;
+ if (!element || typeof ResizeObserver === 'undefined') {
+ return;
+ }
+ report(element.clientWidth);
+ const observer = new ResizeObserver((entries) => {
+ const width = entries[0]?.contentRect.width ?? element.clientWidth;
+ report(width);
+ });
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, [report]);
+
+ const fitScale = size && panelWidth > 0 ? Math.min(1, panelWidth / size.width) : 1;
+ const scale = fitScale * zoom;
+
+ return (
+ <>
+
+
+
+ {children}
+
+
+
+
+
+ >
+ );
+}
diff --git a/application/v2_ui/src/components/chat/MermaidDiagram.tsx b/application/v2_ui/src/components/chat/MermaidDiagram.tsx
index c219a82d7..e29f0ea1d 100644
--- a/application/v2_ui/src/components/chat/MermaidDiagram.tsx
+++ b/application/v2_ui/src/components/chat/MermaidDiagram.tsx
@@ -8,9 +8,23 @@
// The library is loaded from the vendored copy on first use rather than bundled. It is 3.4 MB
// — larger than the rest of the application put together — and most conversations never show
// a diagram.
+//
+// Sizing lives in DiagramStage.tsx. The expanded viewer lives here rather than in its own file
+// on purpose: it writes diagram markup to the DOM, and keeping every such sink in one reviewed
+// file is what test_v2_rich_rendering.py's sanitizer boundary check is protecting.
-import { useEffect, useMemo, useRef, useState } from 'react';
-import { Download, TriangleAlert } from 'lucide-react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ ChevronDown,
+ Copy,
+ Download,
+ Maximize2,
+ Minus,
+ Plus,
+ Scan,
+ TriangleAlert,
+ X,
+} from 'lucide-react';
import { useUiStore } from '../../stores/uiStore';
import type { DomPurifyStatic, MermaidStatic } from '../../lib/vendor';
import { VENDOR_PATHS, loadDomPurify, loadVendorScript } from '../../lib/vendorAssets';
@@ -23,9 +37,26 @@ import {
visualStyleSignature,
type VisualStyle,
} from '../../lib/visualPalettes';
+import {
+ describeMermaidError,
+ isRepairWorthTrying,
+ repairMermaidSource,
+} from '../../lib/mermaidSource';
import { downloadDataUri, fileNameStem, svgElementToPngDataUri } from '../../lib/svgRaster';
import { registerExportDiagram } from '../../lib/exportVisuals';
import { VisualStyleMenu } from './VisualStyleMenu';
+import {
+ clampZoom,
+ defaultStageHeight,
+ DiagramStage,
+ MAX_ZOOM,
+ MIN_FIGURE_WIDTH,
+ MIN_ZOOM,
+ readDiagramSize,
+ ZOOM_STEP,
+ type DiagramSize,
+} from './DiagramStage';
+import { GlassPanel } from '../ui/primitives';
interface MermaidRuntime {
mermaid: MermaidStatic;
@@ -37,6 +68,44 @@ let runtimeLoad: Promise | null = null;
let configuredSignature: string | null = null;
let idCounter = 0;
+/**
+ * How long one diagram is given before it is treated as failed.
+ *
+ * Matches MERMAID_RENDER_TIMEOUT_MS in static/js/chat/chat-mermaid-runtime.js. Without it a
+ * render that never settles leaves "Rendering diagram…" on screen for the life of the page,
+ * which is indistinguishable from a diagram that is merely slow.
+ */
+const RENDER_TIMEOUT_MS = 10000;
+
+/**
+ * Longest diagram source that is attempted at all.
+ *
+ * Matches INLINE_DIAGRAM_MAX_SOURCE_LENGTH in static/js/chat/chat-inline-diagrams.js. Mermaid
+ * has its own `maxTextSize`, but it reports the refusal as a render failure; checking first
+ * lets the reader be told the diagram is too large rather than that it is broken.
+ */
+const MAX_SOURCE_LENGTH = 30000;
+
+/**
+ * Ceilings handed to mermaid, set explicitly rather than left to its defaults.
+ *
+ * `maxEdges` matters: a diagram at mermaid's default limit of 500 edges renders roughly fifty
+ * thousand pixels tall, so the limit is a rendering safeguard as much as a parsing one and is
+ * worth stating where it can be seen.
+ */
+const MERMAID_MAX_TEXT_SIZE = 50000;
+const MERMAID_MAX_EDGES = 500;
+
+/**
+ * How wide a label is allowed to get before mermaid wraps it.
+ *
+ * Mermaid's default is 200px, which turns the long labels models write into narrow columns of
+ * text: the same diagram measures 273 x 955 at the default and 497 x 867 at this value, so the
+ * default is actively making diagrams taller and harder to read. Diagrams that break their own
+ * labels with ` `, which is what the diagram guidance asks for, are unaffected.
+ */
+const MERMAID_WRAPPING_WIDTH = 500;
+
/**
* Serialises rendering.
*
@@ -108,10 +177,18 @@ function configure(
// rasterizable: a label disappears when an SVG is painted onto a
// canvas, which would produce a PNG with no text in it.
htmlLabels: false,
- flowchart: { htmlLabels: false, useMaxWidth: true },
+ flowchart: {
+ htmlLabels: false,
+ useMaxWidth: true,
+ wrappingWidth: MERMAID_WRAPPING_WIDTH,
+ },
class: { htmlLabels: false, useMaxWidth: true },
sequence: { useMaxWidth: true },
gantt: { useMaxWidth: true },
+ // Stated rather than inherited, so the ceilings a diagram is measured against are
+ // visible next to the code that has to explain hitting them.
+ maxTextSize: MERMAID_MAX_TEXT_SIZE,
+ maxEdges: MERMAID_MAX_EDGES,
// Mermaid otherwise writes its own error diagram straight into the page, outside
// React's control. Failures are handled below instead.
suppressErrorRendering: true,
@@ -147,6 +224,26 @@ function cacheSvg(key: string, svg: string) {
svgCache.set(key, svg);
}
+/** Reject once a render has had long enough, without cancelling the render itself. */
+function withTimeout(work: Promise, timeoutMs: number): Promise {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => reject(new Error('Timed out rendering the diagram.')),
+ timeoutMs,
+ );
+ work.then(
+ (value) => {
+ clearTimeout(timer);
+ resolve(value);
+ },
+ (error) => {
+ clearTimeout(timer);
+ reject(error);
+ },
+ );
+ });
+}
+
async function renderDiagram(
source: string,
theme: string,
@@ -161,6 +258,10 @@ async function renderDiagram(
return cached;
}
+ if (source.length > MAX_SOURCE_LENGTH) {
+ throw new Error('The diagram source is too large to draw.');
+ }
+
const { mermaid, purify } = await loadMermaidRuntime();
const run = renderQueue.then(async () => {
@@ -171,8 +272,27 @@ async function renderDiagram(
configure(mermaid, theme, style, background, configKey);
- idCounter += 1;
- const { svg } = await mermaid.render(`simplechat-mermaid-${idCounter}`, source);
+ const draw = async (text: string) => {
+ idCounter += 1;
+ const { svg } = await withTimeout(
+ Promise.resolve(mermaid.render(`simplechat-mermaid-${idCounter}`, text)),
+ RENDER_TIMEOUT_MS,
+ );
+ return svg;
+ };
+
+ let svg: string;
+ try {
+ svg = await draw(source);
+ } catch (error) {
+ // Second attempt only. Repairing source mermaid has already accepted would risk
+ // changing a diagram that is drawing correctly, so the original is always tried
+ // first and the rewrite is a last resort before showing the reader the source.
+ if (!isRepairWorthTrying(source)) {
+ throw error;
+ }
+ svg = await draw(repairMermaidSource(source));
+ }
// Sanitizer boundary. Mermaid's 'strict' level already sanitizes internally; this
// is the independent second pass required before model-derived markup is written
@@ -191,16 +311,75 @@ async function renderDiagram(
type DiagramState =
| { status: 'pending' }
| { status: 'ready'; svg: string }
- | { status: 'error' };
+ | { status: 'error'; reason: string };
-/** The diagram source, shown when it cannot be rendered. */
+/**
+ * The diagram source, shown when it cannot be rendered.
+ *
+ * The reason mermaid gave is shown rather than swallowed. Before this the only signal a reader
+ * or an administrator had was the words "Diagram could not be rendered", which is not enough to
+ * tell a malformed diagram from a library that failed to load.
+ */
function DiagramSource({ source, reason }: { source: string; reason: string }) {
+ const [detailsOpen, setDetailsOpen] = useState(false);
+ const [copied, setCopied] = useState(false);
+ const copyTimerRef = useRef | null>(null);
+
+ useEffect(
+ () => () => {
+ if (copyTimerRef.current !== null) {
+ clearTimeout(copyTimerRef.current);
+ }
+ },
+ [],
+ );
+
+ const copySource = async () => {
+ try {
+ await navigator.clipboard.writeText(source);
+ setCopied(true);
+ if (copyTimerRef.current !== null) {
+ clearTimeout(copyTimerRef.current);
+ }
+ copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
+ } catch {
+ setCopied(false);
+ }
+ };
+
return (
-
+
- {reason}
+ Diagram could not be rendered
+
+
+
+ {detailsOpen && (
+
+ {reason}
+
+ )}
+
{source}
@@ -208,21 +387,219 @@ function DiagramSource({ source, reason }: { source: string; reason: string }) {
);
}
-/** The first line of a diagram, used to name its downloaded file. */
+/** The first line of a diagram, used to name its downloaded file and title its viewer. */
function diagramName(source: string): string {
const firstLine = source.trim().split('\n', 1)[0] ?? '';
return fileNameStem(firstLine, 'diagram');
}
+/** Zoom in, zoom out and fit, shared by the inline panel and the expanded viewer. */
+function ZoomControls({
+ zoom,
+ onZoom,
+ onReset,
+ compact = false,
+}: {
+ zoom: number;
+ onZoom: (next: number) => void;
+ onReset: () => void;
+ compact?: boolean;
+}) {
+ const buttonClass = compact
+ ? 'shrink-0 rounded-lg p-1.5 text-text-3 transition-colors hover:bg-surface-2 hover:text-text-1 disabled:cursor-not-allowed disabled:opacity-40'
+ : 'inline-flex items-center rounded-md px-1.5 py-1 text-text-3 transition-colors hover:bg-surface-2 hover:text-text-1 disabled:cursor-not-allowed disabled:opacity-40';
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+/**
+ * Full-screen view of one diagram.
+ *
+ * Follows the conventions ImageLightbox established: a click-to-close backdrop, Escape to
+ * dismiss, focus moved in on open and handed back on close, and no focus-trap utility, because
+ * none of the other dialogs in this interface use one.
+ *
+ * The diagram is drawn from the same sanitized markup the inline panel is showing, so nothing
+ * is re-rendered and the two cannot disagree.
+ */
+function DiagramLightbox({
+ svg,
+ size,
+ title,
+ background,
+ onDownload,
+ onClose,
+}: {
+ svg: string;
+ size: DiagramSize | null;
+ title: string;
+ background?: string;
+ onDownload: (element: SVGElement | null) => void;
+ onClose: () => void;
+}) {
+ const [zoom, setZoom] = useState(1);
+ const [fitWidth, setFitWidth] = useState(0);
+ const closeRef = useRef(null);
+ const viewportRef = useRef(null);
+ const contentRef = useRef(null);
+
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ onClose();
+ }
+ };
+ document.addEventListener('keydown', onKeyDown);
+ return () => document.removeEventListener('keydown', onKeyDown);
+ }, [onClose]);
+
+ // Opening a dialog should move the keyboard focus into it, and closing should hand it back
+ // to whatever had it before, which is the button that opened it.
+ useEffect(() => {
+ const previous = document.activeElement as HTMLElement | null;
+ closeRef.current?.focus();
+ return () => previous?.focus?.();
+ }, []);
+
+ useEffect(() => {
+ const element = viewportRef.current;
+ if (!element || typeof ResizeObserver === 'undefined') {
+ return;
+ }
+ setFitWidth(element.clientWidth);
+ const observer = new ResizeObserver((entries) => {
+ setFitWidth(entries[0]?.contentRect.width ?? element.clientWidth);
+ });
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, []);
+
+ const scale = size && fitWidth > 0 ? Math.min(1, fitWidth / size.width) * zoom : zoom;
+
+ return (
+
+
+
+
+
+
+ {title}
+
+
+ setZoom(1)} compact />
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
/**
* A rendered mermaid diagram.
*
* A diagram that fails to parse falls back to its source rather than disappearing: the
* source is still the answer the model gave, and hiding it would lose information.
*
- * `messageId` and `blockIndex` are what a saved colour choice is filed under. A diagram in a
- * reply that is still streaming has neither, so it renders with the reader's default and its
- * colour control says the choice will be kept once the reply finishes.
+ * `messageId` and `blockIndex` are what a saved colour choice and a saved height are filed
+ * under. A diagram in a reply that is still streaming has neither, so it renders with the
+ * reader's defaults and its controls say the choice will be kept once the reply finishes.
*/
export function MermaidDiagram({
source,
@@ -236,15 +613,14 @@ export function MermaidDiagram({
const theme = useUiStore((state) => state.theme);
const [state, setState] = useState({ status: 'pending' });
const [menuOpen, setMenuOpen] = useState(false);
+ const [expanded, setExpanded] = useState(false);
+ const [zoom, setZoom] = useState(1);
+ const [panelWidth, setPanelWidth] = useState(0);
const [downloadError, setDownloadError] = useState(null);
const containerRef = useRef(null);
- const { style, setStyle, reset, canPersist, error } = useBlockVisualStyle(
- 'mermaid',
- source,
- messageId,
- blockIndex,
- );
+ const { style, setStyle, reset, height, setHeight, resetHeight, canPersist, error } =
+ useBlockVisualStyle('mermaid', source, messageId, blockIndex);
// `theme` is a dependency because the "match theme" background resolves through the app's
// own surface colour, which is exactly what the theme switch changes.
@@ -265,7 +641,7 @@ export function MermaidDiagram({
useEffect(() => {
const trimmed = source.trim();
if (trimmed === '') {
- setState({ status: 'error' });
+ setState({ status: 'error', reason: 'The diagram source is empty.' });
return;
}
@@ -284,9 +660,13 @@ export function MermaidDiagram({
setState({ status: 'ready', svg });
}
})
- .catch(() => {
+ .catch((renderError) => {
+ // Logged as well as shown. Someone looking into a report that a diagram will
+ // not draw needs the parser's own words, and the panel only shows them to
+ // whoever happens to be reading that message.
+ console.warn('Unable to render an inline diagram:', renderError);
if (!cancelled) {
- setState({ status: 'error' });
+ setState({ status: 'error', reason: describeMermaidError(renderError) });
}
});
@@ -295,25 +675,34 @@ export function MermaidDiagram({
};
}, [source, theme, style, background, signature]);
+ const svg = state.status === 'ready' ? state.svg : '';
+ const size = useMemo(() => (svg ? readDiagramSize(svg) : null), [svg]);
+
+ // The height someone chose for this diagram, or one derived from how it actually measures.
+ const stageHeight = height ?? defaultStageHeight(size, panelWidth || size?.width || 0);
+
/**
* Save the diagram as it is currently drawn.
*
* Rasterized from the SVG already on screen rather than re-rendered, so the file matches
* what the reader is looking at, colours included.
*/
- const downloadPng = async () => {
- const svg = containerRef.current?.querySelector('svg');
- if (!svg) {
- return;
- }
- setDownloadError(null);
- try {
- const dataUri = await svgElementToPngDataUri(svg, background);
- downloadDataUri(dataUri, `${diagramName(source)}.png`);
- } catch {
- setDownloadError('The diagram could not be saved as an image.');
- }
- };
+ const downloadPng = useCallback(
+ async (element: SVGElement | null) => {
+ const target = element ?? containerRef.current?.querySelector('svg') ?? null;
+ if (!target) {
+ return;
+ }
+ setDownloadError(null);
+ try {
+ const dataUri = await svgElementToPngDataUri(target, background);
+ downloadDataUri(dataUri, `${diagramName(source)}.png`);
+ } catch {
+ setDownloadError('The diagram could not be saved as an image.');
+ }
+ },
+ [background, source],
+ );
/**
* Offer this diagram to a Word, PowerPoint or email export of the same message.
@@ -333,7 +722,7 @@ export function MermaidDiagram({
);
if (state.status === 'error') {
- return ;
+ return ;
}
if (state.status === 'pending') {
@@ -345,44 +734,97 @@ export function MermaidDiagram({
}
return (
-
-
+ )}
+
+
+ {/* Outside the figure, which clips its overflow and would otherwise be an odd place
+ to nest a dialog. */}
+ {expanded && (
+ void downloadPng(element)}
+ onClose={() => setExpanded(false)}
/>
-
-
- {downloadError && !menuOpen && (
-
{downloadError}
)}
-
+ >
);
}
diff --git a/application/v2_ui/src/components/chat/MessageList.tsx b/application/v2_ui/src/components/chat/MessageList.tsx
index a25d046be..42112245f 100644
--- a/application/v2_ui/src/components/chat/MessageList.tsx
+++ b/application/v2_ui/src/components/chat/MessageList.tsx
@@ -1,7 +1,7 @@
// MessageList.tsx
// Renders the message thread, the in-flight streaming bubble and the reasoning panel.
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { clsx } from 'clsx';
import {
Brain,
@@ -145,7 +145,7 @@ function ImageMessage({ message }: { message: ChatMessage }) {
);
}
-function MessageBubble({
+function MessageBubbleInner({
message,
proposalImages,
}: {
@@ -163,7 +163,9 @@ function MessageBubble({
const currentUserId = useBootstrapStore((state) => state.data?.user?.id);
const chatWidth = useUiStore((state) => state.chatWidth);
- const masks = readMaskState(message);
+ // Memoised because it walks the message's mask metadata and is read on every render of
+ // the thread, which is often: the list re-renders on each streaming token.
+ const masks = useMemo(() => readMaskState(message), [message]);
const maskingAllowed = canMask(message, currentUserId);
// A user message is plain text, so its masked spans can be cut straight out of the
@@ -334,6 +336,19 @@ function MessageBubble({
);
}
+/**
+ * A message in the thread, re-rendered only when that message itself changes.
+ *
+ * Without this every message re-runs its whole markdown pipeline — remark, rehype and a fresh
+ * React tree — on each streaming token and each time the scroll position crosses the pinned
+ * threshold, because those both re-render the list. In a thread containing a large diagram
+ * that is enough work per token to lock the interface up.
+ *
+ * The default shallow comparison is exactly right here: `message` is replaced by the store
+ * when it changes, and `proposalImages` comes from a memoised map.
+ */
+const MessageBubble = memo(MessageBubbleInner);
+
/**
* The bubble shown while a response is being generated.
*
@@ -412,16 +427,61 @@ export function MessageList() {
const chatWidth = useUiStore((state) => state.chatWidth);
const scrollRef = useRef(null);
- const bottomRef = useRef(null);
- const [pinnedToBottom, setPinnedToBottom] = useState(true);
+
+ /**
+ * Whether the reader is at the bottom of the thread.
+ *
+ * A ref rather than state on purpose. Held in state, every scroll that crossed the
+ * threshold re-rendered the list and with it every message's markdown, which is expensive
+ * enough to stall a thread containing a large diagram. Nothing on screen depends on it, so
+ * nothing needs to re-render when it changes.
+ */
+ const pinnedRef = useRef(true);
+
+ const scrollToBottom = useCallback(() => {
+ const element = scrollRef.current;
+ if (element) {
+ // Set directly rather than through `scrollIntoView`, which also scrolls every
+ // scrollable ancestor and can drag the page itself around.
+ element.scrollTop = element.scrollHeight;
+ }
+ }, []);
// Auto-scroll only while the user is already at the bottom, so reading back through a
// long answer is not interrupted by incoming tokens.
useEffect(() => {
- if (pinnedToBottom) {
- bottomRef.current?.scrollIntoView({ block: 'end' });
+ if (pinnedRef.current) {
+ scrollToBottom();
}
- }, [messages, streamingContent, pinnedToBottom]);
+ }, [messages, streamingContent, scrollToBottom]);
+
+ /**
+ * Follow content that grows after it was laid out.
+ *
+ * A diagram renders asynchronously: a 96px placeholder is replaced by a panel that can be
+ * several hundred pixels tall, long after the scroll that was meant to land at the bottom.
+ * Nothing re-ran, so the reader was left above the end of the thread, chasing a target that
+ * moved every time another diagram finished.
+ */
+ useEffect(() => {
+ const element = scrollRef.current;
+ if (!element || typeof ResizeObserver === 'undefined') {
+ return;
+ }
+ // The content, not the viewport: the viewport's own size changing is a window resize,
+ // which should not yank the reader to the bottom.
+ const content = element.firstElementChild;
+ if (!content) {
+ return;
+ }
+ const observer = new ResizeObserver(() => {
+ if (pinnedRef.current) {
+ scrollToBottom();
+ }
+ });
+ observer.observe(content);
+ return () => observer.disconnect();
+ }, [scrollToBottom]);
const onScroll = () => {
const element = scrollRef.current;
@@ -430,7 +490,7 @@ export function MessageList() {
}
const distanceFromBottom =
element.scrollHeight - element.scrollTop - element.clientHeight;
- setPinnedToBottom(distanceFromBottom < 80);
+ pinnedRef.current = distanceFromBottom < 80;
};
const isEmpty = useMemo(
@@ -535,8 +595,6 @@ export function MessageList() {
{streamError}
)}
-
-
);
diff --git a/application/v2_ui/src/lib/blockVisualStyle.ts b/application/v2_ui/src/lib/blockVisualStyle.ts
index f2122c203..ff9def3e2 100644
--- a/application/v2_ui/src/lib/blockVisualStyle.ts
+++ b/application/v2_ui/src/lib/blockVisualStyle.ts
@@ -1,10 +1,15 @@
// blockVisualStyle.ts
-// Resolves the colours one diagram or chart should use, and saves a change to them.
+// Resolves the colours and the size one diagram or chart should use, and saves a change to
+// either.
//
-// Three sources are consulted, in increasing precedence: the built-in default, the reader's own
-// default from their settings document, and an override saved against this specific block of
-// this specific message. Because the override wins outright, recolouring one chart leaves every
-// other chart in the conversation exactly as it was.
+// Three sources are consulted for colours, in increasing precedence: the built-in default, the
+// reader's own default from their settings document, and an override saved against this
+// specific block of this specific message. Because the override wins outright, recolouring one
+// chart leaves every other chart in the conversation exactly as it was.
+//
+// The size is simpler: a block either has a height someone dragged it to or it does not, and
+// the two are stored in the same entry but changed independently. Resetting colours must not
+// resize a block, and resizing a block must not stop it following the reader's palette.
//
// A block has no identity of its own, so an override is addressed by the block's position among
// blocks of the same kind in the message, together with a fingerprint of its source. If the
@@ -38,13 +43,13 @@ export const VISUAL_STYLE_SETTING_KEYS: Record = {
*/
const SAVE_DEBOUNCE_MS = 400;
-/** The stored override for one block, or null when there is none that still applies. */
-function readStoredOverride(
+/** The stored entry for one block, or null when there is none that still applies. */
+function readStoredEntry(
metadata: unknown,
kind: VisualStyleKind,
blockIndex: number,
sourceHash: string,
-): VisualStyle | null {
+): Record | null {
if (!metadata || typeof metadata !== 'object') {
return null;
}
@@ -71,9 +76,45 @@ function readStoredOverride(
return null;
}
+ return entry as Record;
+}
+
+/**
+ * The stored colour override for one block, or null when there is none.
+ *
+ * An entry that carries only a height is not a colour override. Treating it as one would stop
+ * a diagram someone merely resized from following the reader's default palette, which is a
+ * change they never asked for. The presence of `palette` is what distinguishes the two,
+ * because the server writes the colour fields together or not at all.
+ */
+function readStoredOverride(
+ metadata: unknown,
+ kind: VisualStyleKind,
+ blockIndex: number,
+ sourceHash: string,
+): VisualStyle | null {
+ const entry = readStoredEntry(metadata, kind, blockIndex, sourceHash);
+ if (!entry || typeof entry.palette !== 'string') {
+ return null;
+ }
return sanitizeVisualStyle(entry);
}
+/** The stored stage height for one block, or null when it has none. */
+function readStoredHeight(
+ metadata: unknown,
+ kind: VisualStyleKind,
+ blockIndex: number,
+ sourceHash: string,
+): number | null {
+ const entry = readStoredEntry(metadata, kind, blockIndex, sourceHash);
+ const height = entry?.height;
+ if (typeof height !== 'number' || !Number.isFinite(height) || height <= 0) {
+ return null;
+ }
+ return Math.round(height);
+}
+
export interface BlockVisualStyle {
/** The colours to render with. */
style: VisualStyle;
@@ -81,6 +122,12 @@ export interface BlockVisualStyle {
setStyle: (next: VisualStyle) => void;
/** Drop the block's own colours so it follows the reader's default again. */
reset: () => void;
+ /** The height the block was left at, or null to size it automatically. */
+ height: number | null;
+ /** Resize the block, immediately on screen and shortly afterwards on the server. */
+ setHeight: (next: number) => void;
+ /** Drop the chosen height so the block is sized automatically again. */
+ resetHeight: () => void;
/**
* True when the choice will be kept.
*
@@ -92,6 +139,19 @@ export interface BlockVisualStyle {
error: string | null;
}
+/**
+ * One queued change.
+ *
+ * `undefined` on either field means "this change says nothing about that", which is how a
+ * resize leaves the colours alone and a recolour leaves the size alone. Queued changes merge
+ * rather than replace, so a recolour immediately followed by a drag still writes both.
+ */
+interface PendingChange {
+ style?: VisualStyle | null;
+ height?: number | null;
+ conversationId: string;
+}
+
export function useBlockVisualStyle(
kind: VisualStyleKind,
source: string,
@@ -125,44 +185,68 @@ export function useBlockVisualStyle(
: null,
[addressable, storedMetadata, kind, blockIndex, sourceHash],
);
+ const storedHeight = useMemo(
+ () =>
+ addressable
+ ? readStoredHeight(storedMetadata, kind, blockIndex as number, sourceHash)
+ : null,
+ [addressable, storedMetadata, kind, blockIndex, sourceHash],
+ );
/** The reader's unsaved change, which takes precedence until the write settles. */
const [draft, setDraft] = useState<{ value: VisualStyle | null } | null>(null);
+ const [heightDraft, setHeightDraft] = useState<{ value: number | null } | null>(null);
const [error, setError] = useState(null);
const applyVisualStyle = useChatStore((state) => state.applyVisualStyle);
const timerRef = useRef | null>(null);
/** The change waiting to be written, with the conversation it was made in. */
- const pendingRef = useRef<{ value: VisualStyle | null; conversationId: string } | null>(
- null,
- );
+ const pendingRef = useRef(null);
+
+ const style = useMemo(() => {
+ const override = draft ? draft.value : storedOverride;
+ return resolveVisualStyle(userDefault, override) ?? DEFAULT_VISUAL_STYLE;
+ }, [draft, storedOverride, userDefault]);
- /** Write a value now, without waiting for the debounce. */
+ const height = heightDraft ? heightDraft.value : storedHeight;
+
+ // Read when a resize is scheduled, so the write carries the colours the block actually has
+ // rather than re-deriving them: sending the resolved style would pin a block that is only
+ // following the reader's default.
+ const effectiveOverrideRef = useRef(null);
+ effectiveOverrideRef.current = draft ? draft.value : storedOverride;
+
+ /** Write a change now, without waiting for the debounce. */
const write = useCallback(
- (value: VisualStyle | null, conversationId: string) => {
+ (change: PendingChange) => {
if (!messageId || typeof blockIndex !== 'number') {
return;
}
+ const styleForWrite =
+ change.style === undefined ? effectiveOverrideRef.current : change.style;
+
void applyVisualStyle(
messageId,
- conversationId,
+ change.conversationId,
kind,
blockIndex,
sourceHash,
- value,
+ styleForWrite,
+ change.height,
).then((saved) => {
- // Either way the draft is dropped: on success the store now holds the stored
- // value, and on failure the block should show what is actually saved rather
+ // Either way the drafts are dropped: on success the store now holds the stored
+ // values, and on failure the block should show what is actually saved rather
// than a change that never landed.
setDraft(null);
- setError(saved ? null : 'Those colours could not be saved.');
+ setHeightDraft(null);
+ setError(saved ? null : 'That change could not be saved.');
});
},
[applyVisualStyle, blockIndex, kind, messageId, sourceHash],
);
const schedule = useCallback(
- (value: VisualStyle | null) => {
+ (change: Omit) => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
@@ -178,11 +262,18 @@ export function useBlockVisualStyle(
return;
}
- pendingRef.current = { value, conversationId };
+ const merged: PendingChange = {
+ ...(pendingRef.current ?? {}),
+ ...(change.style === undefined ? {} : { style: change.style }),
+ ...(change.height === undefined ? {} : { height: change.height }),
+ conversationId,
+ };
+
+ pendingRef.current = merged;
timerRef.current = setTimeout(() => {
timerRef.current = null;
pendingRef.current = null;
- write(value, conversationId);
+ write(merged);
}, SAVE_DEBOUNCE_MS);
},
[addressable, write],
@@ -199,9 +290,9 @@ export function useBlockVisualStyle(
timerRef.current = null;
}
if (pendingRef.current) {
- const { value, conversationId } = pendingRef.current;
+ const change = pendingRef.current;
pendingRef.current = null;
- writeRef.current(value, conversationId);
+ writeRef.current(change);
}
},
[],
@@ -211,7 +302,7 @@ export function useBlockVisualStyle(
(next: VisualStyle) => {
setError(null);
setDraft({ value: next });
- schedule(next);
+ schedule({ style: next });
},
[schedule],
);
@@ -219,13 +310,32 @@ export function useBlockVisualStyle(
const reset = useCallback(() => {
setError(null);
setDraft({ value: null });
- schedule(null);
+ schedule({ style: null });
}, [schedule]);
- const style = useMemo(() => {
- const override = draft ? draft.value : storedOverride;
- return resolveVisualStyle(userDefault, override) ?? DEFAULT_VISUAL_STYLE;
- }, [draft, storedOverride, userDefault]);
+ const setHeight = useCallback(
+ (next: number) => {
+ setError(null);
+ setHeightDraft({ value: Math.round(next) });
+ schedule({ height: Math.round(next) });
+ },
+ [schedule],
+ );
+
+ const resetHeight = useCallback(() => {
+ setError(null);
+ setHeightDraft({ value: null });
+ schedule({ height: null });
+ }, [schedule]);
- return { style, setStyle, reset, canPersist: addressable, error };
+ return {
+ style,
+ setStyle,
+ reset,
+ height,
+ setHeight,
+ resetHeight,
+ canPersist: addressable,
+ error,
+ };
}
diff --git a/application/v2_ui/src/lib/endpoints.ts b/application/v2_ui/src/lib/endpoints.ts
index de90aeb73..ab2470d68 100644
--- a/application/v2_ui/src/lib/endpoints.ts
+++ b/application/v2_ui/src/lib/endpoints.ts
@@ -241,12 +241,14 @@ export const maskMessage = (
/* Message visual styles */
/* -------------------------------------------------------------------------- */
-/** Colours saved against one diagram or chart inside a message. */
+/** Colours and size saved against one diagram or chart inside a message. */
export interface VisualStyleEntry {
palette?: string;
background?: string;
colors?: Record;
source_hash?: string;
+ /** Stage height in pixels, set by dragging the block's resize handle. */
+ height?: number;
}
/** Every saved entry for a message, keyed by fence language then by block index. */
@@ -260,11 +262,15 @@ export interface VisualStyleResponse {
}
/**
- * Save, or clear, the colours for one block of one message.
+ * Save, or clear, the colours and size for one block of one message.
*
- * A null `style` removes the entry so the block follows the reader's own default again, which
- * is a different outcome from saving a style that happens to equal that default: the default
- * can change later.
+ * A null `style` removes the colours so the block follows the reader's own default again,
+ * which is a different outcome from saving a style that happens to equal that default: the
+ * default can change later.
+ *
+ * `height` is deliberately optional rather than nullable-by-default. Omitting the key leaves
+ * whatever size is stored alone, so changing colours never resets a diagram someone resized;
+ * sending null is what clears it.
*
* `conversation_id` lets the server read the message by partition key rather than running a
* cross-partition query, exactly as the mask endpoint does.
@@ -277,6 +283,7 @@ export const setMessageVisualStyle = (
block_index: number;
source_hash: string;
style: { palette: string; background: string; colors: Record } | null;
+ height?: number | null;
},
) =>
api.post(
diff --git a/application/v2_ui/src/lib/mermaidSource.ts b/application/v2_ui/src/lib/mermaidSource.ts
new file mode 100644
index 000000000..c5c55abef
--- /dev/null
+++ b/application/v2_ui/src/lib/mermaidSource.ts
@@ -0,0 +1,389 @@
+// mermaidSource.ts
+// Repairs the mistakes models actually make when writing Mermaid, so a diagram that would
+// otherwise be shown as a wall of source renders instead.
+//
+// Every rule here corresponds to a failure reproduced against the vendored mermaid 11.17.2
+// bundle in Chromium, configured exactly as MermaidDiagram.tsx configures it. Nothing is
+// speculative: a rule that could not be made to fail was not written.
+//
+// This runs only on a SECOND attempt, after mermaid has already rejected the source. A diagram
+// that renders today is handed to mermaid untouched and is never rewritten, so this cannot
+// regress anything that currently works. That ordering matters more than the rules themselves:
+// repairing model output is inherently lossy, and the correct source is always preferred.
+
+/**
+ * Words the flowchart grammar reserves, which cannot be used as a node id.
+ *
+ * `end` is the one models hit constantly, because it is the natural name for the last box in a
+ * flow. `default` is deliberately absent: it parses, so renaming it would be a change with no
+ * failure behind it.
+ */
+const RESERVED_NODE_IDS = new Set(['end', 'graph', 'class', 'style', 'subgraph', 'click']);
+
+/** Suffix appended when a reserved id has to be renamed. Unlikely to collide with a real id. */
+const RENAME_SUFFIX = '_node';
+
+/** Characters that look like quotes to a model but are not the quote the grammar wants. */
+const SMART_QUOTES = /[\u2018\u2019\u201a\u201b\u2032]/g;
+const SMART_DOUBLE_QUOTES = /[\u201c\u201d\u201e\u201f\u2033]/g;
+
+/** Whitespace that survives a copy-paste and is not the whitespace the lexer expects. */
+const EXOTIC_SPACES = /[\u00a0\u2000-\u200a\u202f\u205f\u3000]/g;
+const ZERO_WIDTH = /[\u200b-\u200d\u2060\ufeff]/g;
+
+/** ` ` in any of the spellings a model reaches for. */
+const BR_VARIANTS = /<\s*br\s*\/?\s*>/gi;
+
+/** A quoted label, used only once every label is known to hold no bare quotes. */
+const QUOTED_LABEL = /"([^"\n]*)"/g;
+
+/** A square node declaration: an id, a `[`, its text, and the first `]` after it. */
+const SQUARE_NODE = /([\w-]+)\[([^\]\n]*)\]/g;
+
+/** An edge label between pipes. */
+const EDGE_LABEL = /\|([^|\n]*)\|/g;
+
+/** An opening `subgraph` statement. */
+const SUBGRAPH_LINE = /^\s*subgraph\b/;
+
+/** A terminator, in the only spelling the grammar accepts and the ones it does not. */
+const END_LINE = /^\s*end\s*$/;
+const MISCASED_END_LINE = /^\s*(END|End|eNd|enD|EnD|ENd|eND)\s*$/;
+
+/**
+ * Normalise the characters a model picked up from whatever it was reading.
+ *
+ * A leading byte-order mark is the sharpest of these: it makes the very first token
+ * `\ufeffflowchart` rather than `flowchart`, so mermaid reports that no diagram type was
+ * detected when the source is otherwise perfect.
+ */
+function normalizeCharacters(source: string): string {
+ return source
+ .replace(ZERO_WIDTH, '')
+ .replace(/\r\n?/g, '\n')
+ .replace(EXOTIC_SPACES, ' ')
+ .replace(SMART_QUOTES, "'")
+ .replace(SMART_DOUBLE_QUOTES, '"')
+ .replace(BR_VARIANTS, ' ');
+}
+
+/**
+ * True for a line that is a comment rather than a statement.
+ *
+ * Used to leave alone any line the statement-level rules should not touch.
+ */
+function isCommentLine(line: string): boolean {
+ return /^\s*%%/.test(line);
+}
+
+/** Placeholder standing in for ` ` while the rest of a label is escaped around it. */
+const BR_PLACEHOLDER = '\u0001BR\u0001';
+
+/** Delimiter for text held aside while a pattern runs over everything around it. */
+const STASH_OPEN = '\u0002';
+
+/** Put stashed text back where it came from. */
+function unstash(text: string, stash: string[]): string {
+ return text.replace(
+ new RegExp(`${STASH_OPEN}(\\d+)${STASH_OPEN}`, 'g'),
+ (_match, index: string) => stash[Number(index)],
+ );
+}
+
+/**
+ * Escape everything in a label's text that the grammar or the sanitizer would take as syntax.
+ *
+ * A bare `"` is the sharpest of these: the string token runs to the next quote, so a label like
+ * `"He said "hello" loudly"` ends after `said ` and the remainder is parsed as syntax. Braces
+ * and angle brackets are escaped for the same reason, and because a model transcribing a header
+ * such as `x-ms-client-request-id: ` has no idea it is writing markup — mermaid's
+ * sanitizer would otherwise drop the placeholder entirely and lose the text.
+ *
+ * ` ` is protected first. It is the one piece of markup a label is meant to contain, and it
+ * is what the guidance in functions_diagram_operations.py explicitly asks models to use.
+ */
+function escapeLabelText(text: string): string {
+ return text
+ .split(' ')
+ .join(BR_PLACEHOLDER)
+ .replace(/&(?![a-zA-Z][a-zA-Z0-9]{1,10};|#\d{1,6};|#x[0-9a-fA-F]{1,5};)/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>')
+ .replace(/\{/g, '{')
+ .replace(/\}/g, '}')
+ .split(BR_PLACEHOLDER)
+ .join(' ');
+}
+
+/**
+ * Rewrite one label's raw text into a quoted, escaped label.
+ *
+ * Returns null when nothing needs doing, which is what keeps the repair a no-op for a diagram
+ * that is merely a different kind of broken — and what lets `isRepairWorthTrying` answer
+ * honestly.
+ */
+function repairLabelBody(body: string): string | null {
+ const trimmed = body.trim();
+ if (trimmed === '') {
+ // An empty label fails to parse. A single space keeps the node, unlabelled, rather
+ // than dropping it: it is still part of the answer the model gave.
+ return ' ';
+ }
+
+ const quoted = trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"');
+ const inner = quoted ? trimmed.slice(1, -1) : trimmed;
+
+ if (inner.trim() === '') {
+ return ' ';
+ }
+
+ const escaped = escapeLabelText(inner);
+ if (quoted && escaped === inner) {
+ return null;
+ }
+ return escaped;
+}
+
+/**
+ * Quote and escape node and edge labels.
+ *
+ * Only the square node form and the piped edge form are handled. Those are where every
+ * reproduced failure came from, and they are the two whose extent can be determined without
+ * guessing: a body containing the shape's own delimiter is skipped rather than mis-split.
+ *
+ * Square nodes are rewritten first and stashed. A pipe is legal inside a node label, and the
+ * edge-label pattern has no notion of quoting, so without stashing it would pair a pipe inside
+ * a label with the pipe that opens the real edge label and rewrite the arrow between them.
+ */
+function repairLabels(line: string): string {
+ const stash: string[] = [];
+
+ const withNodes = line.replace(SQUARE_NODE, (_match, id: string, body: string) => {
+ let rebuilt: string;
+ if (body.includes('[')) {
+ rebuilt = `${id}[${body}]`;
+ } else {
+ const fixed = repairLabelBody(body);
+ rebuilt = fixed === null ? `${id}[${body}]` : `${id}["${fixed}"]`;
+ }
+ stash.push(rebuilt);
+ return `${STASH_OPEN}${stash.length - 1}${STASH_OPEN}`;
+ });
+
+ // An odd number of remaining pipes means they cannot all be label delimiters, so pairing
+ // them would be a guess. Left alone rather than rewritten wrongly.
+ const pipes = (withNodes.match(/\|/g) ?? []).length;
+ const withEdges =
+ pipes % 2 === 0
+ ? withNodes.replace(EDGE_LABEL, (_match, body: string) => {
+ // `||` is an empty edge label in a flowchart, but it is also cardinality in
+ // an erDiagram. Left alone either way: there is nothing in it to repair.
+ if (body.trim() === '') {
+ return `|${body}|`;
+ }
+ const fixed = repairLabelBody(body);
+ return fixed === null ? `|${body}|` : `|"${fixed}"|`;
+ })
+ : withNodes;
+
+ return unstash(withEdges, stash);
+}
+
+/**
+ * True when the source is a flowchart.
+ *
+ * Every rule below the character normalisation is flowchart grammar. `subgraph`, `end`, square
+ * node labels and piped edge labels all mean something else, or nothing, in the ten other
+ * diagram types mermaid supports, and rewriting a sequence diagram with flowchart rules would
+ * turn one failure into a different one.
+ */
+function isFlowchart(source: string): boolean {
+ for (const line of source.split('\n')) {
+ const trimmed = line.trim();
+ if (trimmed === '' || trimmed.startsWith('%%')) {
+ continue;
+ }
+ return /^(flowchart|graph)\b/.test(trimmed);
+ }
+ return false;
+}
+
+/**
+ * Rename node ids the grammar reserves.
+ *
+ * Every occurrence of the identifier is rewritten, not just its declaration, so the edges that
+ * referred to it still connect. Bounded to word boundaries, and skipped inside quoted labels,
+ * comments and `subgraph` terminators, so prose that merely contains the word is untouched.
+ *
+ * Excluding terminators is what makes `end` safe to rename at all. A lowercase `end` on its own
+ * line closes a `subgraph`; renaming those alongside a node called `end` would silently move
+ * everything that followed a terminator inside the group it was meant to close, and the result
+ * still parses, so the reader would be shown a diagram with the wrong structure rather than an
+ * error.
+ */
+function renameReservedIds(source: string): string {
+ const stash: string[] = [];
+ const hold = (text: string) => {
+ stash.push(text);
+ return `${STASH_OPEN}${stash.length - 1}${STASH_OPEN}`;
+ };
+
+ const stashed = source
+ .split('\n')
+ .map((line) => {
+ if (isCommentLine(line) || END_LINE.test(line)) {
+ return hold(line);
+ }
+ return line.replace(QUOTED_LABEL, (match) => hold(match));
+ })
+ .join('\n');
+
+ let repaired = stashed;
+ for (const reserved of RESERVED_NODE_IDS) {
+ // A declaration: the reserved word immediately followed by a shape opener. `subgraph`
+ // and `click` are keywords followed by a space, so only the shape form can be a node.
+ const declaration = new RegExp(`(^|[^\\w-])${reserved}(\\s*[\\[({])`, 'gm');
+ if (!declaration.test(repaired)) {
+ continue;
+ }
+ repaired = repaired.replace(
+ new RegExp(`(^|[^\\w-])${reserved}(?![\\w-])`, 'gm'),
+ (_match, prefix: string) => `${prefix}${reserved}${RENAME_SUFFIX}`,
+ );
+ }
+
+ return unstash(repaired, stash);
+}
+
+/**
+ * Fix the terminator's case and close any subgraph that was left open.
+ *
+ * The grammar accepts only lowercase `end`. An unclosed `subgraph` swallows everything after
+ * it, so the error mermaid reports points at the last line of the diagram rather than at the
+ * statement that is actually wrong — which makes it one of the harder failures to read.
+ */
+function balanceSubgraphs(source: string): string {
+ const lines = source.split('\n').map((line) => {
+ if (MISCASED_END_LINE.test(line)) {
+ return line.replace(/(END|End|eNd|enD|EnD|ENd|eND)/, 'end');
+ }
+ return line;
+ });
+
+ let open = 0;
+ for (const line of lines) {
+ if (isCommentLine(line)) {
+ continue;
+ }
+ if (SUBGRAPH_LINE.test(line)) {
+ open += 1;
+ } else if (END_LINE.test(line) && open > 0) {
+ open -= 1;
+ }
+ }
+
+ for (let index = 0; index < open; index += 1) {
+ lines.push('end');
+ }
+
+ return lines.join('\n');
+}
+
+/**
+ * Drop a statement that names a source but no target.
+ *
+ * A reply truncated mid-diagram ends in a dangling `A -->`, which fails to parse and takes the
+ * whole diagram with it. The rest of the diagram is still worth drawing.
+ */
+function dropDanglingEdges(source: string): string {
+ return source
+ .split('\n')
+ .filter((line) => !/^\s*[\w-]+\s*(-{2,}>?|={2,}>?|-\.->?)\s*$/.test(line))
+ .join('\n');
+}
+
+/** Split node declarations that a model ran together onto one line. */
+function splitRunTogetherStatements(source: string): string {
+ return source
+ .split('\n')
+ .map((line) => {
+ if (isCommentLine(line) || /-->|---|-\.-|==>/.test(line)) {
+ return line;
+ }
+ const indent = /^\s*/.exec(line)?.[0] ?? '';
+ return line.replace(
+ /(\]|\)|\})\s+(?=[\w-]+\s*[\[({])/g,
+ (_match, close: string) => `${close}\n${indent}`,
+ );
+ })
+ .join('\n');
+}
+
+/**
+ * A best-effort rewrite of diagram source mermaid has already rejected.
+ *
+ * Pure and synchronous, so it can be unit tested without a browser. Returns the source
+ * unchanged when nothing applies, which the caller uses to skip a pointless second render.
+ */
+export function repairMermaidSource(source: string): string {
+ if (!source) {
+ return source;
+ }
+
+ const normalized = normalizeCharacters(source);
+ if (!isFlowchart(normalized)) {
+ return normalized.trim();
+ }
+
+ let repaired = splitRunTogetherStatements(normalized);
+ repaired = repaired
+ .split('\n')
+ .map((line) => (isCommentLine(line) ? line : repairLabels(line)))
+ .join('\n');
+ repaired = renameReservedIds(repaired);
+ repaired = balanceSubgraphs(repaired);
+ repaired = dropDanglingEdges(repaired);
+
+ return repaired.trim();
+}
+
+/** True when repairing would produce something different, so a retry is worth attempting. */
+export function isRepairWorthTrying(source: string): boolean {
+ const trimmed = source.trim();
+ return repairMermaidSource(trimmed) !== trimmed;
+}
+
+/**
+ * A short, readable reason a diagram could not be drawn.
+ *
+ * Mermaid's own messages are multi-line parser dumps with a caret diagram in them. The first
+ * line carries the useful part; the rest belongs in the details disclosure, not in a summary.
+ * The two limit errors are recognised and reworded, because "Edge limit exceeded" tells a
+ * reader nothing about what to do next.
+ */
+export function describeMermaidError(error: unknown): string {
+ const raw =
+ error instanceof Error
+ ? error.message
+ : typeof error === 'string'
+ ? error
+ : '';
+ const text = raw.trim();
+
+ if (!text) {
+ return 'The diagram could not be drawn.';
+ }
+ if (/edge limit exceeded/i.test(text)) {
+ return 'The diagram has too many connections to draw.';
+ }
+ if (/maximum text size/i.test(text)) {
+ return 'The diagram source is too large to draw.';
+ }
+ if (/no diagram type detected/i.test(text)) {
+ return 'The first line does not name a diagram type mermaid recognises.';
+ }
+
+ const firstLine = text.split('\n', 1)[0].trim();
+ return firstLine.length > 200 ? `${firstLine.slice(0, 197)}…` : firstLine;
+}
diff --git a/application/v2_ui/src/lib/vendor.d.ts b/application/v2_ui/src/lib/vendor.d.ts
index 72e44997d..129e6b368 100644
--- a/application/v2_ui/src/lib/vendor.d.ts
+++ b/application/v2_ui/src/lib/vendor.d.ts
@@ -57,7 +57,17 @@ export interface MermaidConfig {
fontFamily?: string;
/** False keeps labels as SVG text rather than embedded foreignObject HTML. */
htmlLabels?: boolean;
- flowchart?: { htmlLabels?: boolean; useMaxWidth?: boolean };
+ flowchart?: {
+ htmlLabels?: boolean;
+ useMaxWidth?: boolean;
+ /**
+ * Pixel width a node label wraps at.
+ *
+ * Mermaid's default of 200 turns the long labels models write into narrow columns of
+ * text, which makes a diagram taller and harder to read rather than shorter.
+ */
+ wrappingWidth?: number;
+ };
sequence?: { useMaxWidth?: boolean };
gantt?: { useMaxWidth?: boolean };
class?: { htmlLabels?: boolean; useMaxWidth?: boolean };
diff --git a/application/v2_ui/src/stores/chatStore.ts b/application/v2_ui/src/stores/chatStore.ts
index c87b97ef1..2a413769e 100644
--- a/application/v2_ui/src/stores/chatStore.ts
+++ b/application/v2_ui/src/stores/chatStore.ts
@@ -177,6 +177,13 @@ interface ChatState {
blockIndex: number,
sourceHash: string,
style: VisualStyle | null,
+ /**
+ * The block's stage height in pixels.
+ *
+ * `undefined` leaves whatever is stored alone, so a colour change does not reset a
+ * size someone chose; `null` clears it back to the automatic height.
+ */
+ height?: number | null,
) => Promise;
sendFeedback: (
messageId: string,
@@ -1174,6 +1181,7 @@ export const useChatStore = create((set, get) => ({
blockIndex,
sourceHash,
style,
+ height,
) => {
if (!conversationId) {
return false;
@@ -1192,6 +1200,9 @@ export const useChatStore = create((set, get) => ({
colors: style.colors,
}
: null,
+ // Spread rather than always sent: the server distinguishes an absent key,
+ // which keeps the stored height, from an explicit null, which clears it.
+ ...(height === undefined ? {} : { height }),
});
set((state) => ({
diff --git a/docs/explanation/features/MERMAID_DIAGRAM_RENDERING.md b/docs/explanation/features/MERMAID_DIAGRAM_RENDERING.md
index 06b82d536..da7636173 100644
--- a/docs/explanation/features/MERMAID_DIAGRAM_RENDERING.md
+++ b/docs/explanation/features/MERMAID_DIAGRAM_RENDERING.md
@@ -107,6 +107,49 @@ The V2 interface solves the same problem the same way inside `MermaidDiagram.tsx
The V2 interface needed no rendering change. It picks up the prompt guidance and starts
receiving diagrams it could already draw.
+## Recovering from a diagram that will not parse
+
+Models get Mermaid wrong in a small number of predictable ways, and one bad character takes the
+whole diagram with it. The V2 renderer therefore repairs and retries before giving up. Each of
+the following was reproduced as a real parse failure against the vendored Mermaid 11.17.2 bundle
+in Chromium, and each renders after repair:
+
+| What the model wrote | Why it failed |
+|---|---|
+| `end["End"]` as a node id | `end` is reserved; so are `graph`, `class` and `style` |
+| `End` closing a `subgraph` | Only lowercase `end` is accepted |
+| A `subgraph` with no terminator | Swallows the rest of the diagram, and the error points at the last line |
+| `a[""]` | An empty label does not parse |
+| `a[App (main)]` | Unquoted parentheses |
+| `a -->|metadata: {}| b` | Unquoted braces in an edge label |
+| `a["He said "hello" loudly"]` | The string token ends at the second quote |
+| `a["A"] b["B"]` | Two declarations on one line |
+| A trailing `b -->` | A truncated reply leaves a dangling edge |
+| A leading byte-order mark | Reported as "no diagram type detected" |
+
+Two rules keep this safe:
+
+- **The repair only ever runs after Mermaid has already refused the source.** A diagram that
+ parses is handed over untouched, so nothing that renders today can be changed by it.
+- **It is scoped to flowcharts.** `subgraph`, `end`, square labels and piped edge labels mean
+ something else in the other diagram types — `||--o{` in an `erDiagram`, for instance — so any
+ other diagram type is left alone.
+
+When repair does not help, the panel shows the source with the reason behind **Show details**,
+and the reason is written to the browser console. Mermaid's limit errors are reworded: "Edge
+limit exceeded. 500 edges found, but the limit is 500." becomes "The diagram has too many
+connections to draw."
+
+The prompt guidance was extended to head off the same failures at the source: it names the
+reserved words, requires a lowercase `end` for every `subgraph`, tells the model not to carry
+placeholders such as `` out of pasted text into a label, and asks for short labels
+across several nodes instead of one node holding a dozen ` ` lines.
+
+## Viewing a diagram in the V2 interface
+
+Sizing, zoom, the drag-to-resize handle and the full-screen viewer are described in
+[V2 Diagram And Chart Styling](V2_DIAGRAM_AND_CHART_STYLING.md).
+
## Testing and validation
`functional_tests/test_mermaid_diagram_prompt_guidance.py` covers diagram intent detection
@@ -119,6 +162,11 @@ headless Chromium: it renders a real diagram and asserts its labels survive, che
pending-fence placeholder, the source fallback for an unparseable diagram, the render
cache, teardown, and that no request leaves the origin.
+`functional_tests/test_v2_diagram_viewer_controls.py` and its bundled
+`test_v2_diagram_viewer_logic.ts` cover the source repair, the error reporting, the render
+timeout and size guard, and the V2 viewer controls. The load-bearing assertion is that the
+repair is a no-op for every diagram Mermaid already accepts.
+
`functional_tests/test_export_mermaid_browser_rasterizer.py` and
`functional_tests/test_export_mermaid_server_render.py` continue to assert that the browser
and server renderers agree on configuration, now reading the browser side from the shared
@@ -127,9 +175,13 @@ runtime.
## Known limitations
Diagram quality depends on the model. The guidance improves the odds that a diagram parses
-and is well chosen, but a model can still emit invalid Mermaid; that case shows the source
+and is well chosen, and the repair pass recovers the common failures, but a model can still
+emit Mermaid that cannot be salvaged; that case shows the source and the parser's reason
rather than failing silently.
+Some failures cannot be repaired at all and are reported instead. A diagram beyond Mermaid's
+500-edge limit is one — it would measure over 50,000 pixels tall even if it parsed.
+
A ` ```mermaid ` fence nested inside a larger fenced block is still treated as a diagram.
This matches the existing behaviour of inline chart blocks.
diff --git a/docs/explanation/features/V2_DIAGRAM_AND_CHART_STYLING.md b/docs/explanation/features/V2_DIAGRAM_AND_CHART_STYLING.md
index 8270fe7c3..2b95ac6aa 100644
--- a/docs/explanation/features/V2_DIAGRAM_AND_CHART_STYLING.md
+++ b/docs/explanation/features/V2_DIAGRAM_AND_CHART_STYLING.md
@@ -137,11 +137,65 @@ refusing to allocate the canvas.
text rather than embedded HTML — and it is also what makes a diagram rasterizable at all, since
a `` label disappears when an SVG is painted onto a canvas.
+## Sizing a diagram
+
+A diagram's panel takes its width from the diagram's own measured natural width, read back out of
+the `max-width` Mermaid writes. This matters more than it sounds: the assistant bubble is
+shrink-to-fit, and Mermaid emits `width="100%"`, which contributes nothing to intrinsic sizing.
+Without a measured width the bubble collapsed to the width of the diagram's toolbar and the
+diagram was drawn at roughly a quarter of its natural size.
+
+The stage the diagram sits in is capped at 520 pixels tall by default and scrolls internally.
+Without a cap a large flowchart goes straight into the message list — one at Mermaid's own limit
+of 500 edges measures over 50,000 pixels tall — where the browser re-rasterizes it on every
+scroll frame.
+
+Three controls change what you see:
+
+| Control | Effect |
+|---|---|
+| `−` / `+` | Scales the diagram between 0.4x and 4x of the scale that fits it to the panel |
+| Fit | Returns to fitting the panel width |
+| **Expand** | Opens the diagram full screen, with its own zoom and PNG download |
+
+The bar along the bottom edge of the stage is a drag handle. It is exposed as a slider, so it can
+be moved with the arrow keys and reset to the automatic height with **Home**. The height you
+leave it at is saved on the message alongside the colours, and the two are independent: resetting
+the colours does not resize the diagram, and resizing it does not stop it following your default
+palette.
+
+Mermaid's `flowchart.wrappingWidth` is set to 500 rather than its default of 200. The default
+wraps the long labels models write into narrow columns of text, which makes a diagram taller and
+harder to read: the same diagram measures 273 x 955 at 200 and 497 x 867 at 500.
+
+## When a diagram will not render
+
+A diagram that Mermaid rejects falls back to its source, because the source is still the answer
+the model gave. The panel says why, behind **Show details**, and offers **Copy source**. The
+reason also goes to the browser console.
+
+Before the reader sees any of that, the source is repaired and rendered once more.
+`repairMermaidSource()` fixes the mistakes models actually make — reserved words used as node
+ids, a capitalised `End`, an unclosed `subgraph`, an empty label, unquoted parentheses or braces,
+a bare quote inside a label, two statements run onto one line, a dangling edge, and stray
+byte-order marks, non-breaking spaces and smart quotes.
+
+Two properties of the repair matter:
+
+- **It only runs after a failure.** A diagram Mermaid accepts is handed over untouched and can
+ never be rewritten, so nothing that renders today can change.
+- **It is scoped to flowcharts.** `subgraph`, `end`, square labels and piped edge labels all mean
+ something else in the other diagram types — `||--o{` in an `erDiagram`, for one — so anything
+ that is not a flowchart is left alone.
+
+Rendering is bounded either way: a diagram is given 10 seconds, and a source longer than 30,000
+characters is refused with its own message rather than being attempted.
+
## API
### `POST /api/message//visual-style`
-Saves or clears the colours for one block.
+Saves or clears the colours, and the chosen height, for one block.
```json
{
@@ -153,13 +207,22 @@ Saves or clears the colours for one block.
"palette": "vivid",
"background": "#ffffff",
"colors": { "0": "#123456" }
- }
+ },
+ "height": 640
}
```
-A `style` of `null` removes the entry, which is not the same as saving a style that happens to
+A `style` of `null` removes the colours, which is not the same as saving a style that happens to
equal your current default: a removed entry follows the default when the default later changes.
+`height` is optional and independent of `style`. **Omitting the key** leaves whatever height is
+stored alone, so changing colours never resets a diagram you resized; **sending `null`** clears
+it so the block is sized automatically again. It is validated as a number and clamped to
+140–2000 pixels rather than refused, because it arrives from a drag and a value a few pixels past
+the limit is someone holding the mouse down, not a client misbehaving.
+
+An entry is removed entirely only once it holds neither colours nor a height.
+
The response returns the whole stored map for the message, not just the entry that changed:
```json
@@ -167,11 +230,15 @@ The response returns the whole stored map for the message, not just the entry th
"success": true,
"message_id": "msg-456",
"visual_styles": {
- "simplechart": { "1": { "palette": "vivid", "background": "#ffffff", "colors": {}, "source_hash": "a1b2c3d4" } }
+ "simplechart": { "1": { "palette": "vivid", "background": "#ffffff", "colors": {}, "source_hash": "a1b2c3d4", "height": 640 } }
}
}
```
+An entry carrying only a `height` is **not** a colour override. The client tells the two apart by
+the presence of `palette`, so a diagram you only resized still follows your default palette
+rather than being pinned to the built-in one.
+
The endpoint authorizes the **conversation**, not the message. A diagram lives in an assistant
message, which carries no author of its own, and authorizing the conversation also admits a
participant acting inside a shared conversation.
@@ -200,7 +267,8 @@ so the accepted form is deliberately narrow:
- Unknown keys in a submitted style are dropped rather than stored, so a client ahead of the
server cannot put arbitrary fields into a message document.
- Sizes are capped: block index 0–199, 24 colour overrides per block, 100 styled blocks per
- message. A message document cannot be grown without bound by repeated requests.
+ message, and a block height of 140–2000 pixels. A message document cannot be grown without
+ bound by repeated requests.
The rendering guarantees that were already in place are unchanged. Mermaid still runs at
`securityLevel: 'strict'` with `htmlLabels: false`, its output still passes through DOMPurify
@@ -212,9 +280,11 @@ as an independent second boundary, and `bindFunctions` is still never called.
|---|---|
| `application/v2_ui/src/lib/visualPalettes.ts` | Presets, colour maths, Mermaid theme mapping, fingerprint, resolver |
| `application/v2_ui/src/lib/svgRaster.ts` | SVG element to PNG data URI |
-| `application/v2_ui/src/lib/blockVisualStyle.ts` | Hook resolving and saving one block's style |
+| `application/v2_ui/src/lib/blockVisualStyle.ts` | Hook resolving and saving one block's colours and height |
+| `application/v2_ui/src/lib/mermaidSource.ts` | Repairs and describes diagram source Mermaid has rejected |
| `application/v2_ui/src/components/chat/VisualStyleMenu.tsx` | The shared colour controls |
-| `application/v2_ui/src/components/chat/MermaidDiagram.tsx` | Diagram rendering, toolbar and PNG download |
+| `application/v2_ui/src/components/chat/MermaidDiagram.tsx` | Diagram rendering, toolbar, PNG download and the full-screen viewer |
+| `application/v2_ui/src/components/chat/DiagramStage.tsx` | Natural-size measurement, the bounded stage and the resize handle |
| `application/v2_ui/src/components/chat/InlineChart.tsx` | Chart rendering and toolbar |
| `application/v2_ui/src/lib/richBlocks.ts` | Fence languages and the streaming placeholder guard |
| `application/v2_ui/src/lib/rehypeRichBlockIndex.ts` | Numbers the blocks on the parsed tree |
@@ -229,11 +299,15 @@ as an independent second boundary, and `bindFunctions` is still never called.
- `functional_tests/test_v2_visual_style_logic.ts` — behavioural checks of the colour and
fence-numbering logic, bundled with esbuild and run by the test above when the front-end
toolchain is installed.
+- `functional_tests/test_v2_diagram_viewer_controls.py` and
+ `functional_tests/test_v2_diagram_viewer_logic.ts` — sizing, the stage cap, the resize handle,
+ the expanded viewer, height storage, and the source repair.
The most important assertion is a negative one: a block nobody has recoloured produces
byte-identical Chart.js configuration to before this feature existed, and a diagram nobody has
recoloured keeps Mermaid's stock `default` or `dark` theme. Existing conversations are
-unaffected.
+unaffected. The repair pass is held to the same standard — it must be a no-op for every diagram
+Mermaid already accepts.
## Known limitations
diff --git a/docs/explanation/fixes/V2_DIAGRAM_VIEWER_FIX.md b/docs/explanation/fixes/V2_DIAGRAM_VIEWER_FIX.md
new file mode 100644
index 000000000..d0ddd0f10
--- /dev/null
+++ b/docs/explanation/fixes/V2_DIAGRAM_VIEWER_FIX.md
@@ -0,0 +1,204 @@
+# V2 Diagram Viewer Fix
+
+Diagrams in the V2 chat rendered too small to read, made long threads unusable, and sometimes
+did not render at all with nothing to explain why.
+
+Fixed in version: **0.261.037**
+
+## The reports
+
+1. Diagrams render "really tiny, kind of hard to see" — but clicking **Colors** makes the same
+ diagram large enough to read.
+2. A long diagram makes the chat "reload" when scrolling, and the bottom of the thread becomes
+ unreachable.
+3. Some diagrams show **Diagram could not be rendered** and produce no logs at all.
+4. There is no way to make a diagram bigger.
+
+## Root causes
+
+Each was reproduced against the vendored mermaid 11.17.2 bundle in Chromium, configured exactly
+as `MermaidDiagram.tsx` configures it, before anything was changed.
+
+### 1 and the Colors jump: the diagram contributed no width
+
+The assistant bubble is a shrink-to-fit flex item — `bubbleWidthClass()` supplies a `max-width`
+and nothing else, so its width is `min(max-content of its contents, max-width)`.
+
+Mermaid renders with `useMaxWidth: true`, which emits:
+
+```html
+