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:
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user