wave 1 (platform stream): background-observability accuracy + owner-driven follow-ups

- observability: liveness-gated stale-job recovery, extraction run registry on globalThis with
  explicit 'embedding' phase, /api/jobs force-dynamic, client refresh nudges at every seam
- headless: providerSafePng canonicalizer (multi-IDAT Chrome PNGs → single-IDAT) fixing qwen 400s
- composer: Enter routes through pure enterAction — accepts a pending plan verdict (owner decision)
- markdown chrome: session-scoped mermaid overlay + md-blocks component set + pan-zoom math,
  corner-copy (clipboard grant in electron main), math/table/code block fixes, heading weight 900

543/543 tests; typecheck/lint/build clean
This commit is contained in:
amosblomqvist
2026-09-19 13:16:34 +03:00
parent cbecb8df03
commit 487f5d4cea
27 changed files with 1754 additions and 111 deletions
+21 -2
View File
@@ -75,6 +75,15 @@ const ActivityContext = createContext<{ feed: ActivityFeed; refresh: () => void
* Polls /api/activity for the whole app shell. Adaptive interval: fast while
* anything runs, slow when idle, ~4× slower on hidden tabs. A failed fetch
* keeps the last feed (the dock never flickers to empty on a blip).
*
* ACCURACY (2026-09-19): polling alone can only be as fresh as its interval,
* so the start sites that the client can know about nudge refresh()
* directly (chat.tsx at turn start/end and dispatch; the extract/import
* buttons), and THIS provider adds the last client-known event: visibility —
* a tab becoming visible refetches at once, so hidden-tab staleness (×4)
* ends the moment the learner looks. The poll stays the safety net for
* everything with no client-visible cause (other tabs, wake turns,
* recovery).
*/
export function ActivityProvider({ children }: { children: React.ReactNode }) {
const [feed, setFeed] = useState<ActivityFeed>(EMPTY_FEED);
@@ -91,7 +100,7 @@ export function ActivityProvider({ children }: { children: React.ReactNode }) {
if (res.ok) {
const data = (await res.json()) as Partial<ActivityFeed>;
if (alive)
setFeed({ jobs: data.jobs ?? [], extractions: data.extractions ?? [] });
setFeed({ jobs: data.jobs ?? [], extractions: data.extractions ?? [] });
}
} catch {
// transport blip: keep the last feed, try again
@@ -107,6 +116,16 @@ export function ActivityProvider({ children }: { children: React.ReactNode }) {
};
}, [hasRunning, nonce]);
// The visibility nudge: returning to the tab refetches immediately (and
// the re-run effect below resets the poll timer — no double polling).
useEffect(() => {
const onVisibility = () => {
if (document.visibilityState === "visible") setNonce((n) => n + 1);
};
document.addEventListener("visibilitychange", onVisibility);
return () => document.removeEventListener("visibilitychange", onVisibility);
}, []);
return <ActivityContext.Provider value={{ feed, refresh }}>{children}</ActivityContext.Provider>;
}
@@ -128,7 +147,7 @@ export interface ActivityBlob {
process: "job" | "extraction";
/** Sub-agent kind for jobs (extraction blobs have none). */
jobKind?: JobKind;
/** Extraction phase (extracting/auditing/merging); jobs carry none. */
/** Extraction phase (extracting/auditing/merging/embedding); jobs carry none. */
phase?: string;
title: string;
chatId: string;
+6 -21
View File
@@ -15,10 +15,8 @@ import { cn } from "@/lib/utils";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { createMathPlugin } from "@streamdown/math";
import { createMermaidPlugin } from "@streamdown/mermaid";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { MermaidConfig } from "mermaid";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import {
createContext,
@@ -31,11 +29,10 @@ import {
} from "react";
import remarkBreaks from "remark-breaks";
import {
type MermaidOptions,
Streamdown,
type PluginConfig,
} from "streamdown";
import { MermaidError } from "@/components/mermaid-error.tsx";
import { mdComponents } from "@/components/md-blocks";
import { transformMathDelimiters } from "@/src/core";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
@@ -344,8 +341,10 @@ export type MessageResponseProps = ComponentProps<typeof Streamdown>;
// metastring parsing. The slot appends after the defaults, which is the
// standard remark-gfm → remark-breaks order.
//
// mermaid: the plugin supplies the renderer; the `mermaid` PROP (below)
// carries the dark theme + the error fallback component.
// MERMAID is app-owned since the chrome-melt wave (2026-09-19): the md-blocks
// `code` override intercepts mermaid fences and renders via
// components/mermaid-render.ts — streamdown's internal mermaid machinery
// (the plugin entry + the `mermaid` prop) is retired with it.
export const streamdownPlugins = {
cjk: {
...cjk,
@@ -353,22 +352,8 @@ export const streamdownPlugins = {
},
code,
math: createMathPlugin({ singleDollarTextMath: false }),
mermaid: createMermaidPlugin(),
} as unknown as PluginConfig;
// The app is dark-only (html.dark in the root layout) — diagrams render in
// mermaid's dark theme; parse failures degrade to the dark-styled fallback.
const mermaidTheme: MermaidConfig = {
fontFamily: "ui-sans-serif, system-ui, sans-serif",
securityLevel: "strict",
theme: "dark",
};
export const mermaidOptions: MermaidOptions = {
config: mermaidTheme,
errorComponent: MermaidError,
};
export const MessageResponse = memo(
({ className, children, ...props }: MessageResponseProps) => {
// Delimiter preprocessing is a single linear scan (no markdown parsing)
@@ -389,7 +374,7 @@ export const MessageResponse = memo(
"lh-prose size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
mermaid={mermaidOptions}
components={mdComponents}
plugins={streamdownPlugins}
{...props}
>
+3 -2
View File
@@ -22,7 +22,8 @@ import {
import { Streamdown } from "streamdown";
import { transformMathDelimiters } from "@/src/core";
import { mermaidOptions, streamdownPlugins } from "./message";
import { streamdownPlugins } from "./message";
import { mdComponents } from "@/components/md-blocks";
import { Shimmer } from "./shimmer";
interface ReasoningContextValue {
@@ -215,7 +216,7 @@ export const ReasoningContent = memo(
)}
{...props}
>
<Streamdown mermaid={mermaidOptions} plugins={streamdownPlugins}>
<Streamdown components={mdComponents} plugins={streamdownPlugins}>
{transformMathDelimiters(children)}
</Streamdown>
</CollapsibleContent>
+76 -4
View File
@@ -27,6 +27,7 @@ import { useActivity } from "@/components/activity-dock";
import { mergeAdopted, type ChatSettings } from "@/src/core";
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
import { PlanMarginalia } from "@/components/plan-window";
import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay";
import { WaypointsIcon } from "lucide-react";
import type { PlanBlockData } from "@/src/core";
import { QuestionDock } from "@/components/question-dock";
@@ -350,7 +351,14 @@ export function Chat({
adoptionWindowOpenRef.current = true;
const timers = [400, 1000, 2000].map((ms) =>
setTimeout(() => {
if (adoptionWindowOpenRef.current) void adoptAppendOnly();
if (adoptionWindowOpenRef.current) {
void adoptAppendOnly();
// Turn-start nudge re-fire (accuracy wave): the graph-context
// job starts at send — the initial refresh above can race the
// server's job-record write; the window's ticks retry it so the
// dock always catches the job within the window (≤2s).
refreshActivity();
}
}, ms),
);
// Deliberately no cleanup here: this effect re-runs on every status
@@ -371,6 +379,11 @@ export function Chat({
// The live plan popup (wave 4): opened by any transcript plan block, the
// top-bar plan chip, and the dock band's marginalia.
const [planOpen, setPlanOpen] = useState(false);
// The mermaid focus (chrome-melt wave, 2026-09-19): md-blocks' expand
// button registers the chart source here; the overlay (and the plan
// popup) mount INSIDE the session-area div — both are session-scoped, so
// the drag bar stays sharp and draggable while a focus owns the area.
const [mermaidFocus, setMermaidFocus] = useState<string | null>(null);
// The session's live plan (drives the top-bar chip; the marginalia
// derives its own from the same transcript).
const planTree = useMemo(() => latestTree(messages), [messages]);
@@ -472,6 +485,14 @@ export function Chat({
// immediate: turns-only refetch — the stats bar updates even while
// the server's persist/endTurn lags the client by milliseconds
void syncTurns();
// Turn-end nudge (accuracy wave, 2026-09-19): the extraction
// pipeline fires at this turn's onFinish — the dock's poll would
// discover a started run up to 10s late. Refetch the activity feed
// now (stream settle) and again at +600/+1500ms (bounded window —
// covers onFinish→triggerExtraction→setPhase lag); the feed's own
// fast interval takes over once it sees the run.
refreshActivity();
const nudgeTimers = [600, 1500].map((ms) => setTimeout(() => refreshActivity(), ms));
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let adopted = false;
@@ -503,10 +524,11 @@ export function Chat({
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
nudgeTimers.forEach(clearTimeout);
};
}
prevStatusRef.current = status;
}, [status, chatId, syncFromServer, syncTurns]);
}, [status, chatId, syncFromServer, syncTurns, refreshActivity]);
// ── Central job observability (contracts.ts "THE ASYNC PROTOCOL" §4) ──
// ONE poll of /api/jobs drives every job surface (transcript dispatch
@@ -540,9 +562,41 @@ export function Chat({
const jobStatusRef = useRef<Map<string, string>>(new Map());
// A settle seen during a busy turn — the sync is owed, not dropped.
const pendingSyncRef = useRef(false);
// Dispatch nudge (accuracy wave, 2026-09-19): a research/demo/visual part
// that cleared input streaming proves the server-side execute ran (the
// bridge emits input-available only at tool_execution_end — AFTER
// dispatch() created the job record on disk), so nudging the activity
// feed here is race-free. The dock learns a dispatched job at the moment
// of cause, not at the next idle poll tick (up to 10s). Keyed by
// toolCallId; the persistent set makes each dispatch nudge exactly once
// (transcript parts stay forever — the diff only fires on new ids).
const dispatchedRef = useRef<Set<string>>(new Set());
useEffect(() => {
const live = new Set<string>();
for (const m of messages) {
for (const p of m.parts) {
if (
(p.type === "tool-research" || p.type === "tool-demo" || p.type === "tool-visual") &&
p.toolCallId &&
p.state !== "input-streaming" &&
p.state !== "output-error"
) {
live.add(p.toolCallId);
}
}
}
for (const id of live) {
if (!dispatchedRef.current.has(id)) refreshActivity();
}
dispatchedRef.current = live;
}, [messages, refreshActivity]);
useEffect(() => {
if (pollMs === 0) return;
let cancelled = false;
// First-tick guard: the poll's first read adopts existing history
// (jobs recorded before mount) without nudging — a nudge is only owed
// for a running job the feed hasn't seen THIS session.
let firstPoll = jobStatusRef.current.size === 0;
const poll = async () => {
if (cancelled) return;
try {
@@ -551,9 +605,11 @@ export function Chat({
const data = (await res.json()) as { jobs?: JobRecord[]; turnInFlight?: boolean };
if (cancelled) return;
const list = data.jobs ?? [];
let nudgeActivity = false; // settle or new running job this tick
for (const j of list) {
const prev = jobStatusRef.current.get(j.id);
if (prev === "running" && j.status !== "running") {
nudgeActivity = true; // a settle — the blob should leave the feed NOW
// The record is only guaranteed complete when no turn is in
// flight — locally or server-side (a coalesced settle fires
// mid-turn by design), so the FULL sync waits for idle.
@@ -567,8 +623,13 @@ export function Chat({
void syncFromServer();
}
}
// A running job this feed has never seen (wake/respawn started
// server-side, or a dispatch in ANOTHER tab) — nudge so the
// dock adopts it at once.
if (prev === undefined && j.status === "running" && !firstPoll) nudgeActivity = true;
jobStatusRef.current.set(j.id, j.status);
}
firstPoll = false;
if (
pendingSyncRef.current &&
!localStreamRef.current &&
@@ -578,6 +639,7 @@ export function Chat({
void syncFromServer();
}
setJobs(list);
if (nudgeActivity) refreshActivity();
// Wake-turn adoption: a server-side teacher turn with no client request
// of ours (sub-agent delivery) — the same machinery as returning
// mid-turn: busy state + record poll to completion.
@@ -592,7 +654,7 @@ export function Chat({
cancelled = true;
clearInterval(timer);
};
}, [pollMs, jobPartCount, chatId, syncFromServer, adoptAppendOnly]);
}, [pollMs, jobPartCount, chatId, syncFromServer, adoptAppendOnly, refreshActivity]);
// Re-attach after mid-turn navigation (the key fix): poll the record until
// the orphaned turn finishes (turnInFlight flips false server-side in
@@ -647,6 +709,7 @@ export function Chat({
return (
<ChatActionsContext.Provider value={{ chatId, syncFromServer, sendText: (t: string) => void sendMessage({ text: t }) }}>
<SessionMermaidContext.Provider value={{ openMermaid: setMermaidFocus }}>
<JobsContext.Provider value={jobs}>
{/* The session surface: the transcript column (header + scroller) and
the plan-tree panel as full-height SIBLINGS — the panel spans top to
@@ -932,11 +995,20 @@ export function Chat({
/>
<ConversationScrollButton />
</Conversation>
{/* The session-scoped focuses (chrome-melt wave, 2026-09-19): mounted
INSIDE the session area — absolute inset-0 siblings of the scroller,
so they own the area below the drag bar, right of the sidebar, and
the top bar stays sharp + draggable while a focus is open. */}
{mermaidFocus !== null && (
<MermaidOverlay chart={mermaidFocus} onClose={() => setMermaidFocus(null)} />
)}
{planOpen && <PlanPopup messages={messages} onClose={() => setPlanOpen(false)} />}
</div>
</div>
</div>
{planOpen && <PlanPopup messages={messages} onClose={() => setPlanOpen(false)} />}
</JobsContext.Provider>
</SessionMermaidContext.Provider>
</ChatActionsContext.Provider>
);
}
+29 -7
View File
@@ -11,6 +11,20 @@ import { cn } from "@/lib/utils";
* Corner-radius math (concentric with the shell): shell rounded-2xl (16px) −
* row padding p-1.5 (6px) = button rounded-[10px] = 25% of the 40px button.
*/
/** What one Enter press does, in precedence order (the plan-verdict
* behavior is user-decided 2026-09-19, docs/probe-teach-split.md §4):
* stop a busy stream; ACCEPT when the plan verdict pends — typed text
* rides the acceptance as the note (the green button's exact semantics;
* an empty box accepts too, so Enter can never strand the verdict);
* otherwise send the message. Pure so the static-render harness can pin
* the decision (key events can't be simulated in renderToStaticMarkup). */
export function enterAction(state: { busy: boolean; verdictPending: boolean; canSend: boolean }): "stop" | "accept" | "send" | "none" {
if (state.busy) return "stop";
if (state.verdictPending) return "accept";
if (state.canSend) return "send";
return "none";
}
export function Composer({
busy,
value,
@@ -43,8 +57,10 @@ export function Composer({
className="flex items-end gap-1.5 p-1.5"
onSubmit={(e) => {
e.preventDefault();
if (busy) onStop();
else if (canSend) onSend();
const action = enterAction({ busy, verdictPending: Boolean(verdict), canSend });
if (action === "stop") onStop();
else if (action === "accept") verdict?.onAccept();
else if (action === "send") onSend();
}}
>
<textarea
@@ -64,11 +80,15 @@ export function Composer({
// Chat convention: Enter sends, Shift+Enter is a newline.
// ⌘/Ctrl+Enter also sends — the universal submit shortcut
// across every input in the app (thinking field, answers).
// While the plan verdict pends, Enter ACCEPTS the plan instead
// (enterAction — user-decided 2026-09-19).
if (e.key === "Enter" && (e.metaKey || e.ctrlKey || !e.shiftKey)) {
e.preventDefault();
if (busy) onStop();
else if (canSend) onSend();
}
const action = enterAction({ busy, verdictPending: Boolean(verdict), canSend });
if (action === "stop") onStop();
else if (action === "accept") verdict?.onAccept();
else if (action === "send") onSend();
}
}}
placeholder="Ask, answer, or steer the lesson…"
autoFocus={autoFocus}
@@ -78,8 +98,10 @@ export function Composer({
{verdict && !busy ? (
// The plan verdict (docs/probe-teach-split.md §4): accept
// sanctions teaching (phase plan→teach); decline sends the
// revision request. Enter keeps sending a plain message —
// side questions stay possible while the verdict pends.
// revision request. Enter accepts too (user-decided 2026-09-19 —
// it formerly sent a plain message, which left the verdict
// pending on the next message; a side question now rides the
// acceptance as its note instead).
<div className="flex shrink-0 flex-col justify-end gap-1" data-testid="plan-verdict-buttons">
<button
type="button"
+8
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { usePopoverDismiss } from "@/components/popover-dismiss";
import { ListChecksIcon, Loader2Icon } from "lucide-react";
import { useActivity } from "@/components/activity-dock";
import { formatTokens } from "@/components/usage-format";
/**
@@ -35,6 +36,8 @@ interface CoverageChat {
export function GraphCoverageControl({ refreshKey = 0 }: { refreshKey?: number }) {
const [open, setOpen] = useState(false);
// The dock's start-site nudge seam (accuracy wave, 2026-09-19) — see onExtract.
const refreshActivity = useActivity().refresh;
// Click anywhere outside (or Escape) closes the panel (2026-09-17) —
// the root wraps button + panel, so the toggle itself keeps working.
const rootRef = useRef<HTMLDivElement>(null);
@@ -81,6 +84,11 @@ export function GraphCoverageControl({ refreshKey = 0 }: { refreshKey?: number }
// 409 = already running (e.g. the trigger raced us): the row's
// running flag takes over on the next poll.
void load();
// The dock's start-site nudge (accuracy wave, 2026-09-19): the 202
// means setPhase already registered the run (synchronous, before the
// response) — a refresh here is race-free; the 409 also refreshes so
// an unseen in-flight run adopts at once.
refreshActivity();
} catch {
setExtracting((ids) => ids.filter((id) => id !== chatId));
}
+8
View File
@@ -68,6 +68,7 @@ import { incrementalLayout, type GraphNode, type GraphSnapshot } from "@/src/cor
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
import { GraphCoverageControl } from "./graph-coverage.tsx";
import { usePopoverDismiss } from "@/components/popover-dismiss";
import { useActivity } from "@/components/activity-dock";
import {
ARROW_LENGTH_LADDER,
CHARGE_RANGE,
@@ -704,9 +705,16 @@ export function GraphView() {
// graph refetches when they finish (the runs-empty transition).
const fileInputRef = useRef<HTMLInputElement>(null);
const [importing, setImporting] = useState(false);
// The dock's start-site nudge (accuracy wave, 2026-09-19): the import
// POST awaits the WHOLE pipeline server-side, so the run registers long
// before the response — nudge at click and again at +600ms to catch the
// start; the dock's fast active interval carries the run from there.
const refreshActivity = useActivity().refresh;
const onImportFiles = (files: FileList | null) => {
if (!files || files.length === 0) return;
setImporting(true);
refreshActivity();
setTimeout(() => refreshActivity(), 600);
const form = new FormData();
for (const file of Array.from(files)) form.append("files", file);
fetch("/api/graph/import", { method: "POST", body: form })
+345
View File
@@ -0,0 +1,345 @@
"use client";
/**
* MARKDOWN BLOCKS, MELTED INTO THE MESSAGE (owner wave, 2026-09-19): the
* boxed streamdown chrome — the double-framed tables (border + sidebar bg +
* a second inner border + copy/download/fullscreen button rows), the mermaid
* container (border box + mono label header + action pill + inner border +
* in-chart zoom buttons), the code header bars — is REPLACED by this
* component set, wired into Streamdown via its `components` prop:
*
* - Tables: a plain PROSE table (no box, no sidebar bg, no buttons bar) —
* hairline row rules + a stronger header underline (globals.css), wide
* tables scroll inside the column.
* - Mermaid: the bare chart, part of the message — no label, no container,
* no zoom pill (zoom/pan lives in the session-scoped focus overlay now).
* - Code: streamdown's CodeBlock skeleton keeps the shiki plumbing, but the
* container melts (subtle bg tint, no border, no header — globals.css) and
* the buttons go.
* - Display math: untouched visually, but gains the corner copy.
*
* THE ONE AFFORDANCE (user-decided): a small muted COPY icon at each block's
* top-right corner, revealed on hover — copying the SOURCE (table →
* Markdown, code → text, mermaid → chart source, math → the raw TeX). It
* never fakes a table column or shifts layout, and hover-reveal keeps the
* reading surface clean — the whole point of the melt. Text selection stays
* free: the icon lives in dead corner space. Mermaid additionally gets an
* EXPAND icon (opens the session overlay) when a chat page is mounted.
*/
import { isValidElement, useEffect, useRef, useState, type ComponentProps, type ReactNode } from "react";
import { CheckIcon, CopyIcon, MaximizeIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
CodeBlock,
extractTableDataFromElement,
tableDataToMarkdown,
useIsCodeFenceIncomplete,
} from "streamdown";
import type { ExtraProps } from "streamdown";
import { MERMAID_FONT_SIZE, renderMermaid } from "@/components/mermaid-render";
import { MermaidError } from "@/components/mermaid-error";
import { chartSize, useSessionMermaid } from "@/components/session-overlay";
// ─── The one affordance: the hover-reveal corner copy ────────────────────────
/** Clipboard write with a fallback: navigator.clipboard needs the
* `clipboard-sanitized-write` permission, which Electron's deny-by-default
* handler (electron/main.cjs) must grant from the loopback origin — and
* browsers with a stale/absent API reject too. The hidden-textarea
* execCommand path is the belt under that: a failed write would otherwise
* be a silent no-op, which is exactly what the owner saw. Returns whether
* the copy landed (drives the Copied pill). */
async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
/* fall through to the legacy path */
}
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand("copy");
ta.remove();
return ok;
} catch {
return false;
}
}
/** A small muted copy icon for a block's top-right corner. The CONSUMER'S
* wrapper carries `group/md relative` (a NAMED group — the message container
* is also a `group`, and an unnamed group-hover would light EVERY corner in
* the message at once, the all-at-once the owner saw) and positions this
* via className; the icon self-hides until hover (opacity-0 →
* group-hover/md:opacity-100). Copies getText() on click; the icon flips
* to a check for a beat — enough feedback on its own (a "Copied" pill was
* tried and removed by owner ask, 2026-09-19). */
export function CopyCorner({
getText,
label,
className,
}: {
getText: () => string;
label: string;
className?: string;
}) {
const [copied, setCopied] = useState(false);
const timerRef = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(timerRef.current), []);
return (
<span className={cn("relative inline-flex", className)}>
<button
type="button"
aria-label={label}
data-testid="md-copy"
className="flex size-6 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover/md:opacity-100"
onClick={() => {
void copyText(getText()).then((ok) => {
if (!ok) return;
setCopied(true);
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => setCopied(false), 1500);
});
}}
>
{copied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
</button>
</span>
);
}
// ─── Code + mermaid fences (the Streamdown `code` override) ──────────────────
/** The fence source from a code element's children — a bare string in the
* common case; an element wrapping a string when rehype nests it. Pure. */
export function codeSource(children: ReactNode): string {
if (typeof children === "string") return children;
if (typeof children === "number") return String(children);
if (isValidElement(children)) {
const inner = (children.props as { children?: ReactNode }).children;
if (typeof inner === "string") return inner;
}
return "";
}
/** Mermaid, in the message flow: the bare chart (dark theme, rendered via
* the app's own plugin — components/mermaid-render.ts), the hover corner
* with copy + expand, the house error fallback. No container chrome, no
* zoom pill — the zoom/pan home is the session overlay. While the fence is
* still streaming (the trailing open fence), a quiet skeleton instead of a
* doomed parse. */
function MermaidInline({ chart }: { chart: string }) {
const [svg, setSvg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
const overlay = useSessionMermaid();
const incomplete = useIsCodeFenceIncomplete();
// The font-parity cap (owner rule, 2026-09-19): the house CSS
// (`max-width: 100% !important`) overrides mermaid's own natural-size cap,
// so a chart would otherwise stretch to the full column and render its
// text larger than the session's own text. Cap the holder at the width
// where the chart's 16px base font lands at the SESSION font (measured
// live from the wrapper — --lh-prose-size today): a chart may shrink to
// fit the column, but never blow its text up past the chat's.
const holderRef = useRef<HTMLDivElement>(null);
const [capW, setCapW] = useState<number | null>(null);
useEffect(() => {
if (!svg || !holderRef.current) return;
const fs = Number.parseFloat(getComputedStyle(holderRef.current).fontSize);
if (!Number.isFinite(fs) || fs <= 0) return;
const { w } = chartSize(svg);
setCapW((w * fs) / MERMAID_FONT_SIZE);
}, [svg]);
useEffect(() => {
if (incomplete) return;
let cancelled = false;
setSvg(null);
setError(null);
renderMermaid(chart)
.then((s) => {
if (!cancelled) setSvg(s);
})
.catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
});
return () => {
cancelled = true;
};
}, [chart, attempt, incomplete]);
if (incomplete || (svg === null && error === null)) {
return (
<div data-testid="md-mermaid" className="my-4 flex justify-center p-4" aria-busy="true">
<span className="inline-block size-4 animate-spin rounded-full border border-current border-b-2 text-muted-foreground" />
</div>
);
}
if (error !== null) {
return <MermaidError chart={chart} error={error} retry={() => setAttempt((a) => a + 1)} />;
}
return (
// data-streamdown="mermaid" keeps the house CSS hook (the svg
// max-width rule in globals.css) alive for this app-owned block.
<div data-testid="md-mermaid" data-streamdown="mermaid" className="group/md relative my-4">
<div
ref={holderRef}
className="mx-auto flex w-full justify-center"
style={capW === null ? undefined : { maxWidth: `${capW}px` }}
dangerouslySetInnerHTML={{ __html: svg ?? "" }}
/>
<div className="absolute top-0 right-0 flex items-start gap-0.5">
<CopyCorner getText={() => chart} label="Copy diagram source" />
{overlay && (
<button
type="button"
aria-label="Expand diagram"
data-testid="md-mermaid-expand"
title="Expand into the session view"
className="flex size-6 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/md:opacity-100"
onClick={() => overlay.openMermaid(chart)}
>
<MaximizeIcon className="size-3.5" />
</button>
)}
</div>
</div>
);
}
/** The Streamdown `code` override: inline code passes through with the
* house inline classes; block fences split — mermaid renders as the bare
* chart above, everything else delegates to streamdown's exported
* CodeBlock (shiki highlighting + line numbers preserved) inside a
* hover-copy wrapper, with NO children so its sticky button row never
* mounts. The container/look is CSS-melted in globals.css
* (data-streamdown="code-block"…). */
export function MarkdownCode(props: ComponentProps<"code"> & ExtraProps) {
const incomplete = useIsCodeFenceIncomplete();
const { children, className, node, ...rest } = props;
// Inline code: no data-block (the pre handler stamps block fences only).
if (!("data-block" in props)) {
return (
<code className={cn("rounded bg-muted px-1.5 py-0.5 font-mono text-sm", className)} data-streamdown="inline-code" {...rest}>
{children}
</code>
);
}
const language = /language-([^\s]+)/.exec(className ?? "")?.[1] ?? "";
const meta = (node?.properties?.metastring as string | undefined) ?? "";
const source = codeSource(children);
if (language === "mermaid") return <MermaidInline chart={source} />;
const startMatch = /startLine=(\d+)/.exec(meta);
const startLine = startMatch ? Number.parseInt(startMatch[1]!, 10) : undefined;
const lineNumbers = !/\bnoLineNumbers\b/.test(meta);
return (
<div className="group/md relative" data-testid="md-code">
<CopyCorner className="absolute top-1.5 right-2 z-10" getText={() => source} label="Copy code" />
<CodeBlock
code={source}
language={language}
className={className}
isIncomplete={incomplete}
startLine={startLine !== undefined && startLine >= 1 ? startLine : undefined}
lineNumbers={lineNumbers}
/>
</div>
);
}
// ─── Tables (the Streamdown `table` override) ────────────────────────────────
/** A plain PROSE table: no wrapper box, no button rows — just the table
* (streamdown's inner cells keep their padding/typography; the hairline
* rules live in globals.css on the data-streamdown attributes) inside a
* horizontal-scroller for wide ones, with the corner copy. The copy
* serializes the REAL DOM table (the library's own extractor → Markdown). */
export function MarkdownTable(props: ComponentProps<"table"> & ExtraProps) {
const { children, node, ...rest } = props;
const ref = useRef<HTMLTableElement>(null);
return (
<div className="group/md relative my-4" data-testid="md-table">
<CopyCorner
// Contained in the table's top-right corner (owner note, 2026-09-19:
// the old -top-3 lifted only half the 24px button above the 12px
// offset, so it straddled the top outline). 4px inset, z above the
// cells it hovers over; the scroll container stays under it (the
// corner is fixed to the wrapper, not the scrolling table).
className="absolute top-1 right-1 z-10"
label="Copy table"
getText={() => (ref.current ? tableDataToMarkdown(extractTableDataFromElement(ref.current)) : "")}
/>
<div className="overflow-x-auto">
<table
ref={ref}
// No text-sm here — the table text runs at the session prose size
// (owner note, 2026-09-19: streamdown's cells pin their own text-sm
// 12.5px; globals.css overrides cells to inherit, so tables match the
// 16px chat text for a consistent look).
className="w-full border-collapse"
data-streamdown="table"
{...rest}
>
{children}
</table>
</div>
</div>
);
}
// ─── Display math (the Streamdown `span` override) ────────────────────────────
/** KaTeX display equations are wrapped in a SPAN.katex-display (rehype-katex
* renders them as spans with display:block CSS — NOT divs; verified in the
* static render). They gain the corner copy (the raw TeX — KaTeX embeds it
* in the MathML <annotation>; read from the DOM on click, no re-parse).
* Every other span passes through untouched. */
export function MathDisplay(props: ComponentProps<"span"> & ExtraProps) {
const { children, className, node, ...rest } = props;
const ref = useRef<HTMLSpanElement>(null);
if (!className?.includes("katex-display")) {
return (
<span className={className} {...rest}>
{children}
</span>
);
}
return (
// BLOCK, not inline: the katex-display child is itself display:block
// (KaTeX's CSS), and an inline wrapper around a block child gets
// FRAGMENTED by the anonymous-block rules — the absolutely positioned
// corner then has no clean containing block and pointer events never
// land on it (owner report: the math corner neither highlights on hover
// nor copies; everything else works). display:block gives the corner a
// real box; z-10 keeps it above the equation spans it overlays.
<span className="group/md relative block z-0" data-testid="md-math">
<CopyCorner
className="absolute top-0 right-0 z-10"
label="Copy equation source"
getText={() => ref.current?.querySelector("annotation")?.textContent ?? ""}
/>
<span ref={ref} className={className} {...rest}>
{children}
</span>
</span>
);
}
// ─── The stable override set (module identity: Streamdown is memoized) ────────
export const mdComponents = {
code: MarkdownCode,
table: MarkdownTable,
span: MathDisplay,
};
+42
View File
@@ -0,0 +1,42 @@
/**
* The app's ONE mermaid renderer (the chrome-melt wave, 2026-09-19): both the
* in-chat block (components/md-blocks.tsx) and the session-scoped focus
* overlay (components/session-overlay.tsx) render charts through this module
* — one dark-theme config, one plugin instance, unique render ids. This
* REPLACES streamdown's internal mermaid path entirely: its fullscreen portal
* was hard-wired `fixed inset-0` (covering the window-drag bar, X landing in
* drag territory) with no styling hook, which is what drove the custom
* component in the first place.
*/
import { createMermaidPlugin } from "@streamdown/mermaid";
import type { MermaidConfig } from "mermaid";
/** The chart's base font size (px) — mermaid sizes ALL text relative to
* this, so it's the number the display-width cap (md-blocks) divides by:
* a chart shown at its natural width renders text at exactly this size.
* Pinned explicitly so a mermaid default change can't silently shift the
* cap (owner rule, chrome-melt tweaks wave, 2026-09-19: chart text must
* never render larger than the session's own text). */
export const MERMAID_FONT_SIZE = 16;
// The app is dark-only (html.dark in the root layout) — diagrams render in
// mermaid's dark theme. (Moved from ai-elements/message.tsx when the
// renderer became app-owned; the old `mermaid` prop wiring is retired.)
export const mermaidTheme: MermaidConfig = {
fontFamily: "ui-sans-serif, system-ui, sans-serif",
fontSize: MERMAID_FONT_SIZE,
securityLevel: "strict",
theme: "dark",
};
const plugin = createMermaidPlugin();
/** Render one chart source to an svg string. The id is unique per call —
* mermaid caches rendered ids, and a repeated id across charts/renders
* returns the stale svg (the bug streamdown's own id minting avoids). */
export async function renderMermaid(chart: string): Promise<string> {
const id = `lh-mermaid-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
const { svg } = await plugin.getMermaid(mermaidTheme).render(id, chart);
return svg;
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Shared pan/zoom viewport math — the "fit floor" semantics harvested from
* the plan popup (plan-graph rounds 2–4) so the session overlays (the plan
* focus, the mermaid focus) run the SAME geometry: fully zoomed out IS the
* fit (whichever axis binds), panning locks at the floor (nothing to move),
* wheel zooms to the cursor. Pure on purpose: the overlays are render-tested
* in static markup (no live pointer events), so the math lives here where
* tests can pin it (tests/pan-zoom.test.ts).
*/
/** Zoom ceiling for every surface that uses this math (raised 2×→4× for
* reading node titles closely, owner, plan-graph round 3). */
export const ZOOM_MAX = 4;
/** Viewport breathing room subtracted by the fit. */
export const FIT_PAD = 16;
/** The fit never zooms IN past this — a tiny diagram must not blow up. */
export const FIT_ZOOM_MAX = 1.5;
/** A pan/zoom viewport state (translate + scale; origin top-left). */
export interface Viewport {
zoom: number;
x: number;
y: number;
}
/** The fit zoom for a box in a viewport: never below a true fit (the old
* 0.15 floor could sit ABOVE the fit for wide graphs — the epsilon only
* guards degenerate viewports), never above FIT_ZOOM_MAX. Pure. */
export function fitZoomFor(box: { w: number; h: number }, viewport: { w: number; h: number }): number {
if (!viewport.w || !viewport.h || box.w === 0) return 1;
return Math.max(0.02, Math.min((viewport.w - FIT_PAD) / box.w, (viewport.h - FIT_PAD) / box.h, FIT_ZOOM_MAX));
}
/** Centers a box (at the given zoom) in the viewport. Pure. */
export function fitGraphView(box: { w: number; h: number }, viewport: { w: number; h: number }, zoom: number): Viewport {
return { zoom, x: (viewport.w - box.w * zoom) / 2, y: (viewport.h - box.h * zoom) / 2 };
}
/** Zoom by `factor` keeping `focus` (viewport coordinates) visually fixed,
* clamped to [floor, ZOOM_MAX]. At the floor the view re-fits + RE-CENTERS
* (the fit IS the floor — a locked view there has nothing to move). Pure. */
export function zoomedGraphView(
view: Viewport,
factor: number,
floor: number,
focus: { x: number; y: number },
box: { w: number; h: number },
viewport: { w: number; h: number },
): Viewport {
const zoom = Math.min(Math.max(view.zoom * factor, floor), ZOOM_MAX);
if (zoom === view.zoom) return view;
if (zoom <= floor) return fitGraphView(box, viewport, floor);
const k = zoom / view.zoom;
return { zoom, x: focus.x - (focus.x - view.x) * k, y: focus.y - (focus.y - view.y) * k };
}
+29 -43
View File
@@ -12,6 +12,7 @@ import { layout as dagreLayout } from "dagre-d3-es/src/dagre/index.js";
// Mermaid's default flowchart edge curve: dagre's waypoints drawn through
// d3's curveBasis (the exact recipe dagre-d3's own renderer uses).
import { curveBasis, line } from "d3-shape";
import { fitGraphView, fitZoomFor, zoomedGraphView, type Viewport } from "@/components/pan-zoom";
import type { PlanBlockData, PlanNodeStatus, PlanTree } from "@/src/core";
import { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
@@ -24,7 +25,9 @@ import { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
* scales to the full column width (viewBox = layout extent + GRAPH_PAD,
* so strokes/glow never clip), natural height, capped at the chat prose
* font size (TEXT_CAP_SCALE — fills the column, never oversizes text).
* - Clicking opens the full plan in a full-screen popup overlay —
* - Clicking opens the full plan in a session-scoped focus overlay
* (absolute inside the session area, below the drag bar — chrome-melt
* wave, 2026-09-19; was a fixed inset-0 full-screen popup) —
* the LIVE plan: the latest tree from the transcript with statuses
* derived at click time (contracts/plan.ts derivePlanStatuses). Blocks
* are frozen snapshots rendered exactly as carried (never re-derived);
@@ -584,54 +587,26 @@ export function latestTree(messages: readonly PopupMessage[]): PlanTree | null {
return null;
}
/** Zoom bounds: never below fit (fully zoomed out IS the fit — the whole
* graph visible, whichever axis binds — owner, plan-graph round 2), never
* above ZOOM_MAX (raised 2×→4× for reading node titles closely, owner,
* plan-graph round 3). The old 0.15 floor could sit ABOVE a true fit for
* wide graphs; the fit itself is the floor now. */
const ZOOM_MAX = 4;
const FIT_PAD = 16;
/** The popup shows the glow, so its frame pads by the glow's reach —
* the in-chat block (monochrome, no glow) stays tight at GRAPH_PAD. */
const POPUP_PAD = GLOW_REACH;
const FIT_ZOOM_MAX = 1.5;
interface GraphView {
zoom: number;
x: number;
y: number;
}
function fitZoomFor(box: { w: number; h: number }, viewport: { w: number; h: number }): number {
if (!viewport.w || !viewport.h || box.w === 0) return 1;
// No artificial floor: a wide graph must fit even below 0.15× — the
// epsilon only guards degenerate viewports.
return Math.max(0.02, Math.min((viewport.w - FIT_PAD) / box.w, (viewport.h - FIT_PAD) / box.h, FIT_ZOOM_MAX));
}
function fitGraphView(box: { w: number; h: number }, viewport: { w: number; h: number }, zoom: number): GraphView {
return { zoom, x: (viewport.w - box.w * zoom) / 2, y: (viewport.h - box.h * zoom) / 2 };
}
function zoomedGraphView(
view: GraphView,
factor: number,
floor: number,
focus: { x: number; y: number },
box: { w: number; h: number },
viewport: { w: number; h: number },
): GraphView {
const zoom = Math.min(Math.max(view.zoom * factor, floor), ZOOM_MAX);
if (zoom === view.zoom) return view;
if (zoom <= floor) return fitGraphView(box, viewport, floor);
const k = zoom / view.zoom;
return { zoom, x: focus.x - (focus.x - view.x) * k, y: focus.y - (focus.y - view.y) * k };
}
// The pan/zoom math now lives in components/pan-zoom.ts (shared with the
// mermaid focus overlay since the session-overlay wave, 2026-09-19) — same
// fit-floor semantics: fully zoomed out IS the fit, panning locks at the
// floor, zoom ceiling 4× for reading node titles closely.
/**
* The full plan, live: latest tree + statuses derived at click time. Esc,
* backdrop click, or the X close it; drag pans, wheel zooms to the cursor,
* the zoom floor is the fit (panning locks at it — nothing to move).
*
* SESSION-SCOPED since the chrome-melt wave (2026-09-19): it mounts
* ABSOLUTE inside the chat page's session-area div (chat.tsx) — below the
* 60px drag bar, right of the sidebar — instead of a fixed inset-0 overlay.
* The top bar stays sharp and draggable, and the X can never land in drag
* territory. Surface 80% + blur (owner decision): the transcript ghosts
* through while the focus owns the session area.
*/
export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessage[]; onClose: () => void }) {
const tree = useMemo(() => latestTree(messages), [messages]);
@@ -671,7 +646,7 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
);
const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]);
const [view, setView] = useState<GraphView>({ x: 0, y: 0, zoom: 1 });
const [view, setView] = useState<Viewport>({ x: 0, y: 0, zoom: 1 });
const userAdjustedRef = useRef(false);
const atFloor = view.zoom <= fitZoom + 1e-9;
// The transition stays off until the FIRST fit has been applied — the
@@ -756,7 +731,10 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
return (
<div
data-testid="plan-popup"
className="fixed inset-0 z-50 flex flex-col bg-background/95 p-4 backdrop-blur-sm"
// SESSION-SCOPED (absolute, not fixed): the chat page mounts this
// INSIDE the session-area div — below the drag bar, right of the
// sidebar. 80% + blur: the transcript ghosts through.
className="absolute inset-0 z-40 flex flex-col bg-background/80 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="The full plan"
@@ -794,8 +772,16 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
className="absolute top-0 left-0 origin-top-left"
style={{
transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})`,
// Hidden until the first fit has APPLIED — the fit runs in an
// effect (after paint), so an opacity-gated chart can never flash
// at the initial top-left zoom:1 state (same fix as the mermaid
// overlay; transition-gating alone didn't hide the fly-in the
// owner kept seeing in rounds 4–7).
opacity: settled ? 1 : 0,
transition:
panning || !settled ? "none" : "transform 180ms ease-out",
panning || !settled
? "none"
: "transform 180ms ease-out, opacity 160ms ease-out",
}}
>
<PlanDiagram
+280
View File
@@ -0,0 +1,280 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { fitGraphView, fitZoomFor, zoomedGraphView, type Viewport } from "@/components/pan-zoom";
import { renderMermaid } from "@/components/mermaid-render";
import { MermaidError } from "@/components/mermaid-error";
/**
* SESSION-SCOPED OVERLAYS (owner wave, 2026-09-19): the "fullscreen"
* expansions — the plan focus, this mermaid focus — do NOT cover the app
* chrome. They mount ABSOLUTE INSIDE THE SESSION AREA (the `relative` div
* below the top bar, right of the sidebar, where the transcript and dock
* live), so the 60px drag bar stays sharp, visible, and draggable and the X
* can never land in drag territory — the old `fixed inset-0` overlays put it
* exactly there, which is what drove this redesign. Surface: 80% background +
* blur (user-decided) — the transcript ghosts through while the focus owns
* the session area.
*
* The chart source registers from wherever it lives in the transcript
* (md-blocks' expand button) via the context below; the CHAT PAGE owns the
* open state so this overlay mounts as a sibling of the scroller inside the
* session area — never inside it (an absolute inset-0 inside the scroller
* would cover the scrollable extent, not the visible area).
*/
/** The expand seam: md-blocks' mermaid hover button calls openMermaid with
* the chart source; the chat page (which provides this context) opens the
* overlay. Null when no chat page is mounted (sidebar previews etc.) — the
* expand button simply doesn't render there. */
export const SessionMermaidContext = createContext<{ openMermaid: (chart: string) => void } | null>(null);
export function useSessionMermaid() {
return useContext(SessionMermaidContext);
}
/** A chart svg's natural size — mermaid always emits a viewBox; this is the
* extent at zoom 1 (1 svg unit = 1 px), like the plan diagram's natural
* size. The fallback covers a degenerate no-viewBox svg. Pure. */
export function chartSize(svg: string): { w: number; h: number } {
const m = /<svg[^>]*\bviewBox="([\d.eE+-]+)\s+([\d.eE+-]+)\s+([\d.eE+-]+)\s+([\d.eE+-]+)"/.exec(svg);
if (m) {
const w = Number.parseFloat(m[3]!);
const h = Number.parseFloat(m[4]!);
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) return { w, h };
}
return { w: 600, h: 400 };
}
/** The mermaid focus: the chart at natural size inside a pan/zoom viewport
* (the plan popup's fit-floor semantics — fitGraphView/zoomedGraphView in
* pan-zoom.ts), Esc/X close. Renders its own copy of the chart (mermaid
* ids are per-render), not the injected inline svg. */
export function MermaidOverlay({ chart, onClose }: { chart: string; onClose: () => void }) {
// ── chart render (own instance, own ids) ──
const [svg, setSvg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
setSvg(null);
setError(null);
renderMermaid(chart)
.then((s) => {
if (!cancelled) setSvg(s);
})
.catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
});
return () => {
cancelled = true;
};
}, [chart, attempt]);
// Esc closes.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
// ── pan/zoom viewport (same wiring as the plan popup) ──
const viewportRef = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ w: 0, h: 0 });
useEffect(() => {
const el = viewportRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight }));
ro.observe(el);
return () => ro.disconnect();
}, []);
const box = useMemo(() => (svg ? chartSize(svg) : { w: 0, h: 0 }), [svg]);
const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]);
const [view, setView] = useState<Viewport>({ x: 0, y: 0, zoom: 1 });
const userAdjustedRef = useRef(false);
const atFloor = view.zoom <= fitZoom + 1e-9;
const [settled, setSettled] = useState(false);
const [panning, setPanning] = useState(false);
useEffect(() => {
if (atFloor) userAdjustedRef.current = false;
}, [atFloor]);
useEffect(() => {
if (userAdjustedRef.current || !size.w || !size.h || !svg) return;
setView(fitGraphView(box, size, fitZoom));
const raf = requestAnimationFrame(() => setSettled(true));
return () => cancelAnimationFrame(raf);
}, [fitZoom, size, box, svg]);
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
const factor = Math.exp(-dy * 0.0015);
const rect = el.getBoundingClientRect();
setView((v) =>
zoomedGraphView(v, factor, fitZoom, { x: e.clientX - rect.left, y: e.clientY - rect.top }, box, size),
);
userAdjustedRef.current = true;
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, [fitZoom, box, size]);
const dragRef = useRef<{ px: number; py: number; x: number; y: number; moved: boolean } | null>(null);
const startDrag = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("button")) return;
if (atFloor) return;
dragRef.current = { px: e.clientX, py: e.clientY, x: view.x, y: view.y, moved: false };
setPanning(true);
e.currentTarget.classList.add("cursor-grabbing");
e.currentTarget.setPointerCapture(e.pointerId);
};
const moveDrag = (e: React.PointerEvent<HTMLDivElement>) => {
const d = dragRef.current;
if (!d) return;
d.moved = true;
setView((v) =>
v.zoom <= fitZoom + 1e-9 ? v : { ...v, x: d.x + (e.clientX - d.px), y: d.y + (e.clientY - d.py) },
);
};
const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
const d = dragRef.current;
if (d) {
dragRef.current = null;
if (d.moved) userAdjustedRef.current = true;
}
setPanning(false);
e.currentTarget.classList.remove("cursor-grabbing");
};
const stepZoom = (factor: number) => {
userAdjustedRef.current = true;
setView((v) => zoomedGraphView(v, factor, fitZoom, { x: size.w / 2, y: size.h / 2 }, box, size));
};
const fitView = () => {
userAdjustedRef.current = false;
if (!size.w || !size.h) return;
setView(fitGraphView(box, size, fitZoom));
};
return (
<div
data-testid="mermaid-overlay"
// SESSION-SCOPED (not fixed): mounts absolute inside the chat
// page's session-area div — below the drag bar, right of the
// sidebar. 80% + blur: the transcript ghosts through (owner
// decision, 2026-09-19).
className="absolute inset-0 z-40 flex flex-col bg-background/80 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="Diagram focus"
>
<div className="mb-2 flex items-center gap-2">
<span className="text-sm font-medium">Diagram</span>
<span className="text-xs text-muted-foreground">from this lesson</span>
<button
type="button"
onClick={onClose}
aria-label="Close"
data-testid="mermaid-overlay-close"
className="ml-auto rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<XIcon className="size-4" />
</button>
</div>
<div
ref={viewportRef}
className={cn(
"relative min-h-0 flex-1 touch-none overflow-hidden rounded-xl border select-none",
atFloor ? "cursor-default" : "cursor-grab",
)}
onPointerDown={startDrag}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
{error ? (
<div className="flex h-full items-center justify-center p-4">
<MermaidError chart={chart} error={error} retry={() => setAttempt((a) => a + 1)} />
</div>
) : svg ? (
<>
<div
className="absolute top-0 left-0 origin-top-left"
style={{
width: `${box.w}px`,
transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})`,
// Hidden until the first fit has APPLIED: the fit runs in an
// effect (after paint), and the chart would otherwise flash
// at the initial top-left zoom:1 state for a frame (the
// owner saw exactly that; transition-gating alone doesn't
// hide it — VISIBILITY must be gated on settled too).
opacity: settled ? 1 : 0,
transition:
panning || !settled
? "none"
: "transform 180ms ease-out, opacity 160ms ease-out",
}}
>
{/* data-lh-overlay-chart: globals.css pins the svg to the
wrapper's width (kills mermaid's inline max-width cap)
so the wrapper IS the natural-size box the fit math
centers. */}
<div
data-lh-overlay-chart
className="[&_svg]:h-auto [&_svg]:w-full"
dangerouslySetInnerHTML={{ __html: svg }}
/>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
<span className="text-xs text-muted-foreground/60">Drag to pan · scroll to zoom</span>
<div className="flex gap-1">
<button
type="button"
title="Zoom out"
aria-label="Zoom out"
onClick={() => stepZoom(1 / 1.25)}
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
>
<MinusIcon className="size-3.5" />
</button>
<button
type="button"
title="Fit diagram to view"
aria-label="Fit diagram to view"
onClick={fitView}
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
>
<MaximizeIcon className="size-3.5" />
</button>
<button
type="button"
title="Zoom in"
aria-label="Zoom in"
onClick={() => stepZoom(1.25)}
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
>
<PlusIcon className="size-3.5" />
</button>
</div>
</div>
</>
) : (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<span className="inline-block size-4 animate-spin rounded-full border border-current border-b-2" />
Rendering diagram…
</div>
)}
</div>
</div>
);
}