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
+2
View File
@@ -1,5 +1,7 @@
import { isTurnRunning, listJobs } from "@/src/server";
export const dynamic = "force-dynamic";
/**
* Job status polling endpoint (contracts.ts "THE ASYNC PROTOCOL" §4): the
* client's ONE central poller fetches this while any job runs (and slowly
+78
View File
@@ -178,6 +178,16 @@ body {
color: var(--lh-ink-italic);
}
/* Markdown headings render at the font's BLACK weight (owner ask,
2026-09-19) — 900, not streamdown's font-semibold 600. The variable
Source Sans 3 axis spans 200–900 (next/font loads the full range), so
900 draws the real Black cut. Unlayered CSS beats the utility layer;
:where keeps specificity zero. Chrome titles (CMU Serif) don't live
under .lh-prose and stay untouched. */
.lh-prose :where(h1, h2, h3, h4, h5, h6) {
font-weight: 900;
}
/* lh-input (owner, typography round 2): lh-prose's 1.5 READING leading
makes typing carets tower over the glyphs — the caret fills the line
box (24px at 16px text, ~2× the glyph height). Inputs are not
@@ -264,6 +274,74 @@ body {
overflow-y: hidden;
}
/* ============================================================
* Chrome melt (owner wave, 2026-09-19) — tables and code blocks stop
* being boxes: blocks read as part of the message. These rules override
* streamdown's Tailwind utilities (utilities live in a cascade LAYER;
* unlayered author CSS wins regardless of order).
* ============================================================ */
/* Code blocks: no header bar — one quiet rounded tint so code reads as a
unit. The tint is DARKER than the page (muted #1c1c1c was LIGHTER than the
background #141414 and read as raised — owner note, chrome-melt tweaks
2026-09-19) plus a subtle hairline outline. (md-blocks renders its own
hover copy corner.) */
[data-streamdown="code-block"] {
border: 1px solid var(--border);
background: color-mix(in oklab, var(--background) 86%, black);
border-radius: 0.75rem;
padding: 0.375rem;
}
[data-streamdown="code-block-header"] {
display: none;
}
[data-streamdown="code-block-body"] {
border: 0;
background: transparent;
padding: 0.75rem 1rem;
}
/* Tables: hairline row rules + header underline, and a full outer border
— actual RECTANGLES outlined on all four sides (owner note, chrome-melt
tweaks 2026-09-19: the melt left the shape missing its left/right/top
edges). border-collapse merges the outer border with the row rules, so
no edge doubles. (md-blocks supplies the wrapper + the copy corner.) */
[data-streamdown="table"] {
border: 1px solid var(--border);
/* Tables run at the SESSION prose size (owner note, 2026-09-19: they
visibly lagged the 16px chat text at streamdown's 12.5px text-sm).
Unlayered CSS beats the cells' own utility-layer text-sm, and the
:where keeps specificity zero — the override wins on layering alone. */
font-size: var(--lh-prose-size);
}
[data-streamdown="table"] :where(th, td) {
font-size: inherit;
}
/* Column separators: a hairline left rule on every cell but the first in
its row (owner ask, 2026-09-19) — border-collapse merges it with the row
rules and the outer border, no doubled lines anywhere. */
[data-streamdown="table"] :where(th, td):not(:first-child) {
border-left: 1px solid var(--border);
}
[data-streamdown="table-header"] {
background: transparent;
border-bottom: 1px solid var(--border);
}
[data-streamdown="table-body"] [data-streamdown="table-row"] {
border-bottom: 1px solid var(--border);
}
/* The mermaid focus overlay's chart wrapper: the svg fills the wrapper
(which is sized to the chart's viewBox — pan-zoom.ts math centers on it),
overriding mermaid's inline max-width cap. Later + same-specificity
than the column rule above, so the !important tie breaks our way; the
overlay's svg never sits under [data-streamdown="mermaid"] anyway. */
[data-lh-overlay-chart] svg {
max-width: none !important;
width: 100% !important;
height: auto !important;
}
/* ============================================================
* Session status — the braille spinner (the one working indicator)
* ============================================================ */
+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>
);
}
+6 -3
View File
@@ -106,9 +106,12 @@ presents the plan → **accept** → **teach (teacher)**
Accept plan / red Decline) while the verdict pends; accept sends a user
message carrying a `data-plan-accepted` data part — THE plan→teach seam
(`phaseAt3` reads it; `convertToModelMessages` drops data parts, so the
teacher sees only the text). Decline asks for a revision; side questions
stay possible (Enter sends a plain message, phase unmoved). Mid-lesson
rewrites need no fresh verdict: acceptance happens once.
teacher sees only the text). Decline asks for a revision. While the
verdict pends, Enter ACCEPTS the plan — typed text rides the acceptance
as its note, an empty box accepts too (user-decided 2026-09-19; formerly
Enter sent a plain message, which left the verdict still pending on the
next message — a side question now rides the acceptance note instead).
Mid-lesson rewrites need no fresh verdict: acceptance happens once.
- The session picker phase derives from transcript markers the same way.
## 5. No global-graph context in sessions (for now)
+12 -5
View File
@@ -225,15 +225,22 @@ function createWindow(url) {
});
const allowedOrigin = () => new URL(url).origin;
// Deny-by-default; the ONLY grants are media (mic/STT groundwork) from
// our own loopback origin. Electron's default is auto-approve-everything
// — we do not ship that.
// Deny-by-default; the ONLY grants are media (mic/STT groundwork) and
// clipboard-sanitized-write — the copy affordances in the transcript
// (md-blocks copy corners) call navigator.clipboard.writeText(), which
// Chromium only honors with that permission (chrome-melt tweaks wave,
// 2026-09-19; before this grant every copy silently rejected). Still
// loopback-origin only — Electron's default is auto-approve-everything,
// and we do not ship that.
const allowedPermissions = new Set(["media", "clipboard-sanitized-write"]);
const fromOwnOrigin = (origin, permission) =>
origin === allowedOrigin() && allowedPermissions.has(permission);
session.defaultSession.setPermissionRequestHandler((_wc, permission, callback, details) => {
const requestOrigin = details.requestingUrl ? new URL(details.requestingUrl).origin : null;
callback(requestOrigin === allowedOrigin() && permission === "media");
callback(fromOwnOrigin(requestOrigin, permission));
});
session.defaultSession.setPermissionCheckHandler((_wc, permission, origin) => {
return origin === allowedOrigin() && permission === "media";
return fromOwnOrigin(origin, permission);
});
win.webContents.on("will-navigate", (event, target) => {
+12
View File
@@ -37,6 +37,18 @@ export function unregisterJobAbort(jobId: string): void {
registry().delete(jobId);
}
/** True while the job's loop is EXECUTING (a controller is registered) —
* the liveness gate for the stale-job sweep: a "running" record with a
* live registration belongs to a loop this process owns (hung or slow, it
* is the one truth — never re-spawn beside it); without one the record is
* an orphan of a process death and recovers immediately. The span is
* loop-start to delivery-completion: the runner's unregistering finally
* runs only after deliver() resolves, by which point finalizeJob has
* flipped the record — so absence of registration is unambiguous. */
export function isJobLive(jobId: string): boolean {
return registry().has(jobId);
}
/** Abort a running job's loop. Returns false when no controller is live —
* the job's loop is not executing (crashed, or in the delivering-result
* phase where the runner has already returned); the caller then owns the
+47 -12
View File
@@ -478,8 +478,14 @@ function runNotesAuditor(
// ─── the pipeline status registry ─────────────────────────────────────────────
/** The pipeline phases, in order — what the home status endpoint shows. */
export type ExtractionPhase = "extracting" | "auditing" | "merging";
/** The pipeline phases, in order — what the home status endpoint shows.
* `embedding` is its own phase (accuracy wave, 2026-09-19): the post-batch
* tail — embed every uncovered proposition, re-cluster, settle the layout
* — used to hide inside `merging`, so a run that had already merged every
* batch sat on "merging" for the whole embedding tail (model load +
* cold-start backfill on first runs = minutes). The phases must tell the
* truth about what the loop is actually waiting on. */
export type ExtractionPhase = "extracting" | "auditing" | "merging" | "embedding";
/** One in-flight pipeline run. Module-level: one process, one registry.
* Exported for the status payload's consumer typing (the dock). */
@@ -502,15 +508,34 @@ export interface RunningExtraction {
controller: AbortController;
}
const runningExtractions = new Map<string, RunningExtraction>();
interface LhExtractionGlobal {
__lhExtractionRuns?: {
/** In-flight pipeline runs, keyed chatId (chat) / src id (notes). */
running: Map<string, RunningExtraction>;
/** The single-flight guard, shared by every pipeline instance. */
inFlight: Set<string>;
/** Last minted startedAt — kept monotonic so two runs starting in the
* same millisecond never collide on the usage idempotency key. */
lastStartedAt: number;
};
}
/** Last minted startedAt — kept monotonic so two runs starting in the same
* millisecond never collide on the usage idempotency key. */
let lastStartedAt = 0;
/** THE RUN REGISTRIES live on globalThis, not module level — the
* turn-liveness lesson: Next dev compiles each route entry as its own
* module instance, so a module-level Map is a different object in the
* /api/activity route than in the pipeline's chat-handler instance —
* the status routes would read an always-empty registry. One process,
* one truth — the same idiom as the turn, wake, and abort registries
* (moved 2026-09-19, the observability-accuracy wave; also makes the
* single-flight guard cross route instances). */
function runs(): NonNullable<LhExtractionGlobal["__lhExtractionRuns"]> {
const g = globalThis as typeof globalThis & LhExtractionGlobal;
return (g.__lhExtractionRuns ??= { running: new Map(), inFlight: new Set(), lastStartedAt: 0 });
}
/** In-flight pipeline runs (oldest first) — the /api/graph/status payload. */
export function extractionStatuses(): RunningExtraction[] {
return [...runningExtractions.values()].sort((a, b) => a.startedAt - b.startedAt);
return [...runs().running.values()].sort((a, b) => a.startedAt - b.startedAt);
}
/** True cancellation of the in-flight run for a chat (the dock's abort
@@ -519,7 +544,7 @@ export function extractionStatuses(): RunningExtraction[] {
* nothing merged, nothing checkpointed, partial usage still persisted.
* Returns false when no run is in flight (idempotent no-op). */
export function abortExtraction(chatId: string): boolean {
const run = runningExtractions.get(chatId);
const run = runs().running.get(chatId);
if (!run) return false;
run.controller.abort();
return true;
@@ -565,17 +590,22 @@ export interface ExtractionPipeline {
export function createExtractionPipeline(deps: ExtractionPipelineDeps): ExtractionPipeline {
const { chatStore, graphStore, embedder: embed } = deps;
const inFlight = new Set<string>();
// THE SHARED REGISTRIES (runs() above — globalThis): every pipeline
// instance (route entries in dev, tests) sees the same runs, the same
// single-flight guard. No module-level state left in this factory.
const runningExtractions = runs().running;
const inFlight = runs().inFlight;
function setPhase(chatId: string, phase: ExtractionPhase, kind: "chat" | "notes", label?: string): void {
const R = runs();
const prev = runningExtractions.get(chatId);
if (!prev) lastStartedAt = Math.max(Date.now(), lastStartedAt + 1);
if (!prev) R.lastStartedAt = Math.max(Date.now(), R.lastStartedAt + 1);
runningExtractions.set(chatId, {
chatId,
kind,
label: prev?.label ?? label, // phase changes never lose the label
phase,
startedAt: prev?.startedAt ?? lastStartedAt,
startedAt: prev?.startedAt ?? R.lastStartedAt,
usage: prev?.usage ?? {}, // phase changes never lose usage
controller: prev?.controller ?? new AbortController(),
});
@@ -742,6 +772,9 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
);
}
const stats = total;
// The embedding tail gets its own phase — the honest label for what
// the run is waiting on here (see ExtractionPhase above).
setPhase(chatId, "embedding", "chat");
// Semantic embeddings (DECIDED 2026-09-13): topics come from
// MEANING, so embed the new propositions BEFORE the settle and
// the namer look at clusters. Failure is non-fatal — clustering
@@ -868,7 +901,9 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
const auditResult: AuditResult = { ...notesResult, attribution: { quiz: {} } };
const stats = mergeFindings(id, auditResult, { chatStore, graphStore }, { messages: null, foldedAt: input.importedAt });
// Same post-merge chain as the chat path: embeddings first
// (topics from meaning), then the settle, then the namer.
// (topics from meaning), then the settle, then the namer —
// under their own phase, exactly like the chat path.
setPhase(id, "embedding", "notes", input.filePath);
await embed(graphStore).catch((err) => {
console.error(`[extraction ${id}] embedding failed (non-fatal):`, err);
});
+71 -2
View File
@@ -179,6 +179,72 @@ function svgViewport(svg: string, opts: RenderSvgOptions): { width: number; heig
};
}
/** Canonicalize a raw Chrome screenshot for the model's eyes (2026-09-19 fix).
*
* WHY: Chrome writes PNGs with a SPLIT IDAT stream (4KB chunks) — and
* Alibaba's vision decoder (behind OpenRouter's qwen3.8-flash, the
* visual-maker's look-model) rejected those outright during the
* 2026-09-18/19 window: "The image format is illegal and cannot be opened",
* deterministically, at every size (50–800px) — while every single-IDAT
* form passed (a handcrafted 1×1, and sharp re-encodes). On a later re-test
* raw Chrome PNGs passed too (the upstream decoder was fixed or is flaky
* per backend), but the canonical form stays: PNG spec says the IDAT chunks
* are an arbitrary SPLIT of one zlib stream, so merging them is the more
* standard shape, byte-identical stream, zero re-encoding.
*
* Pure Node on purpose (no sharp): the process already loads transformers'
* nested sharp + libvips, and importing the root sharp (Next's optional
* dependency) put a SECOND libvips into the process — macOS's ObjC runtime
* warns (duplicate GNotificationCenterDelegate, "mysterious crashes").
* One native image lib per process; app code carries none. */
export function providerSafePng(png: Buffer): Buffer {
const SIG_LEN = 8;
if (png.length < SIG_LEN + 12 || png.subarray(0, SIG_LEN).toString("hex") !== "89504e470d0a1a0a") return png;
// Walk the chunks once, merging the IDAT split into its canonical
// single-chunk form (first IDAT's position, concatenated payload).
const parts: Array<{ type: string; data: Buffer }> = [];
let off = SIG_LEN;
while (off + 12 <= png.length) {
const len = png.readUInt32BE(off);
if (off + 12 + len > png.length) break; // truncated tail — leave as-is
parts.push({ type: png.subarray(off + 4, off + 8).toString("ascii"), data: png.subarray(off + 8, off + 8 + len) });
off += 12 + len;
}
const idats = parts.filter((p) => p.type === "IDAT");
if (idats.length < 2) return png; // already canonical
const merged = Buffer.concat(idats.map((p) => p.data));
let emittedIdat = false;
const out: Buffer[] = [png.subarray(0, SIG_LEN)];
for (const p of parts) {
if (p.type !== "IDAT") out.push(chunk(p.type, p.data));
else if (!emittedIdat) {
out.push(chunk("IDAT", merged));
emittedIdat = true;
}
}
return Buffer.concat(out);
}
/** One PNG chunk (length + type + data + CRC32) — the spec's exact layout. */
function chunk(type: string, data: Buffer): Buffer {
const out = Buffer.alloc(12 + data.length);
out.writeUInt32BE(data.length, 0);
out.write(type, 4, "ascii");
data.copy(out, 8);
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
return out;
}
/** CRC-32 (PNG's polynomial, table-less) for chunk checksums. */
function crc32(buf: Buffer): number {
let c = ~0;
for (const byte of buf) {
c ^= byte;
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
}
return ~c >>> 0;
}
async function renderSvg(svg: string, opts: RenderSvgOptions = {}): Promise<Buffer> {
const dir = tempDir("lh-visual-");
const file = path.join(dir, "figure.html");
@@ -194,8 +260,11 @@ async function renderSvg(svg: string, opts: RenderSvgOptions = {}): Promise<Buff
const { width, height } = svgViewport(svg, opts);
await page.setViewport({ width, height });
await page.goto(pathToFileURL(file).href, { waitUntil: "load", timeout: 30_000 });
const png = (await page.screenshot({ fullPage: true, type: "png" })) as Buffer;
return png;
const shot = (await page.screenshot({ fullPage: true, type: "png" })) as Buffer;
// Chrome's raw split-IDAT PNG is canonicalized before it reaches a
// provider (see providerSafePng) — the visual-maker feeds this exact
// buffer back to the vision model as its only way to "look".
return providerSafePng(Buffer.from(shot));
} finally {
await browser.close().catch(() => {});
try {
+16 -8
View File
@@ -42,7 +42,7 @@ import {
type SteerMessage,
} from "../core/contracts.ts";
import { findPendingToolCall } from "../core/pending.ts";
import { fireJobAbort } from "../agent/subagents/aborts.ts";
import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts";
import { stores } from "./stores.ts";
import { isTurnRunning } from "./turn-liveness.ts";
@@ -445,16 +445,24 @@ async function waitForIdle(chatId: string): Promise<void> {
// ─── crash recovery ──────────────────────────────────────────────────────────
/** Jobs this old with status "running" are orphans (crash/restart). */
const STALE_JOB_MS = 15 * 60_000;
const SWEEP_THROTTLE_MS = 30_000;
/**
* Lazy sweep (called off the /api/jobs poll, throttled): every stale
* "running" job either has a result (the wake never fired — re-deliver,
* idempotently) or doesn't (the loop itself died — re-spawn it).
* Lazy sweep (called off the /api/jobs poll, throttled), LIVENESS-GATED
* (accuracy wave, 2026-09-19): a "running" record without a live loop
* registration (subagents/aborts.ts) is an orphan of a process death —
* re-deliver its stored result (idempotently) or re-spawn the loop, at
* once (the old 15-minute age heuristic waited on ghosts AND re-ran
* hung-but-alive loops beside themselves — the figure-maker incident,
* 2026-09-18). A record WITH a live registration is provably executing
* in THIS process — never touched, however old: a hung first model call
* (zero usage ever billed) stays honestly "running" in the dock until
* END kills it. The abort registration spans loop start to delivery
* completion, so a live registration can never belong to a dead run.
*/
function recoverStaleJobs(): void {
/** Re-run a job's sub-agent loop — exposed for the sweep's tests (the
* /api/jobs poll is its only production caller, disabled under NODE_ENV=test). */
export function recoverStaleJobs(): void {
const s = state();
const now = Date.now();
if (now - s.lastSweepAt < SWEEP_THROTTLE_MS) return;
@@ -463,7 +471,7 @@ function recoverStaleJobs(): void {
for (const chat of stores.chatStore.listChats()) {
for (const job of stores.jobStore.listJobs(chat.id)) {
if (job.status !== "running") continue;
if (now - Date.parse(job.updatedAt) < STALE_JOB_MS) continue;
if (isJobLive(job.id)) continue; // provably executing here — never re-run beside it
if (!stores.chatStore.getChat(job.chatId)) continue;
if (isTurnRunning(job.chatId)) continue; // a wake turn may be delivering it right now
if (job.kind === "graph-context") {
+98
View File
@@ -457,6 +457,49 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
assert.equal(extractionStatuses().length, 0, "the registry clears when the run ends");
});
it("labels the post-batch embedding tail as its own phase — not 'merging' (2026-09-19 accuracy fix)", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
// Gate the embedder so we can observe the registry WHILE the tail
// runs — mergeFindings has already returned, every batch is merged.
let releaseEmbed!: () => void;
const embedGate = new Promise<void>((resolve) => {
releaseEmbed = resolve;
});
let observed: string | undefined;
const gatedEmbedder = async (store: GraphStore) => {
await embedGate;
observed = extractionStatuses().find((r) => r.chatId === chatId)?.phase;
await fakeEmbedder(store);
};
const verdict: AuditResult = { propositions: FINDINGS.propositions, edges: [], attribution: { quiz: {} }, dropped: [] };
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
embedder: gatedEmbedder,
extractor: submitModel("submit_findings", FINDINGS),
auditor: submitModel("submit_review", verdict),
labeler: submitModel("submit_labels", { labels: [] }),
});
const run = pipeline.runExtraction(chatId);
// Wait for the tail: the phase must flip to "embedding" while the
// embedder hangs — the incident this fixes is minutes of "merging"
// AFTER the last merge, so the flip itself is the assertion.
const t0 = Date.now();
while (extractionStatuses().find((r) => r.chatId === chatId)?.phase !== "embedding") {
if (Date.now() - t0 > 2000) {
releaseEmbed();
await run;
assert.fail("the tail never flipped to embedding (stuck at 'merging'?)");
}
await new Promise((r) => setTimeout(r, 10));
}
releaseEmbed();
await run;
assert.equal(observed, "embedding", "still embedding through the tail, never back to 'merging'");
assert.equal(extractionStatuses().length, 0, "clears when done");
});
it("captures per-stage usage live, persists the run's usage into the chat record", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
@@ -587,6 +630,61 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
await assert.rejects(first, /the extractor never submitted findings/);
});
it("registers runs on the process-wide registry (route-instance safety): a second pipeline instance sees the same single-flight state", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
// Gated extractor: the first call hangs until released, so the run
// stays registered while we observe it.
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let calls = 0;
const gatedExtractor = fauxEngine([
async () => {
calls++;
if (calls === 1) await gate;
return fauxAssistantMessage("…");
},
]);
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
embedder: fakeEmbedder,
extractor: gatedExtractor,
auditor: silentModel(),
labeler: submitModel("submit_labels", { labels: [] }),
});
// A SECOND instance over DIFFERENT ledgers — the observer a separate
// route entry's module instance is in dev (the turn-liveness lesson:
// module instances split; globalThis does not).
const observer = createExtractionPipeline({
chatStore,
graphStore: memoryLedgers(),
embedder: fakeEmbedder,
extractor: silentModel(),
auditor: silentModel(),
labeler: submitModel("submit_labels", { labels: [] }),
});
const first = pipeline.runExtraction(chatId);
// The synchronous prefix (setPhase at batch start) registers BEFORE
// the first await — the run is visible the moment it starts.
assert.ok(
extractionStatuses().some((run) => run.chatId === chatId && run.phase === "extracting"),
"the run is registered at start",
);
const g = globalThis as { __lhExtractionRuns?: { running: Map<string, unknown> } };
assert.ok(g.__lhExtractionRuns?.running.has(chatId), "the registry lives on globalThis — no module-instance split can hide runs");
assert.deepEqual(await observer.runExtraction(chatId), { nodes: 0, edges: 0, events: 0 }, "single-flight spans pipeline instances");
release();
await assert.rejects(first, /the extractor never submitted findings/);
assert.ok(!extractionStatuses().some((run) => run.chatId === chatId), "the registry entry clears when the run ends (however it ends)");
assert.ok(!g.__lhExtractionRuns?.running.has(chatId), "nothing leaks into the global registry");
});
it("throws for unknown chats", async () => {
const ledgers = memoryLedgers();
const pipeline = createExtractionPipeline({
+159
View File
@@ -0,0 +1,159 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import * as zlib from "node:zlib";
import { providerSafePng } from "../src/agent/subagents/headless.ts";
/**
* The 2026-09-19 visual-maker fix (the "image format is illegal" 400s):
* Chrome writes PNGs with a SPLIT IDAT stream (4KB chunks); Alibaba's vision
* decoder — behind OpenRouter's qwen3.8-flash, the visual-maker's look-model —
* rejected those during the 2026-09-18/19 window while every single-IDAT form
* passed. providerSafePng merges the split back into the canonical ONE-IDAT
* form (PNG spec: the IDAT chunks are an arbitrary split of ONE zlib stream —
* the merged form carries the byte-identical stream). Pure Node on purpose:
* the process already loads transformers' nested sharp/libvips; a second copy
* (root sharp) triggered macOS's duplicate-libvips ObjC warning. These tests
* pin the property with no native deps at all.
*/
// ── a tiny PNG toolchain (spec-level, no image library) ─────────────────────
const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
function crc32(buf: Buffer): number {
let c = ~0;
for (const byte of buf) {
c ^= byte;
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
}
return ~c >>> 0;
}
function chunk(type: string, data: Buffer): Buffer {
const out = Buffer.alloc(12 + data.length);
out.writeUInt32BE(data.length, 0);
out.write(type, 4, "ascii");
data.copy(out, 8);
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
return out;
}
function walkChunks(png: Buffer): Array<{ type: string; data: Buffer }> {
assert.ok(png.subarray(0, 8).equals(PNG_SIG), "PNG signature");
const chunks: Array<{ type: string; data: Buffer }> = [];
let off = 8;
while (off < png.length) {
const len = png.readUInt32BE(off);
const type = png.subarray(off + 4, off + 8).toString("ascii");
chunks.push({ type, data: png.subarray(off + 8, off + 8 + len) });
off += 12 + len;
}
return chunks;
}
/** Build a VALID multi-IDAT PNG — exactly Chrome's shape: one IHDR, the
* compressed scanlines split across ≥2 IDAT chunks, one IEND. */
function chromeStyleMultiIdatPng(width = 4, height = 4): Buffer {
// Raw scanlines: filter byte 0 + width × RGB pixels (white) — the filter
// byte MUST be 0–4 (libpng rejects anything else).
const stride = 1 + width * 3;
const raw = Buffer.alloc(stride * height);
for (let y = 0; y < height; y++) {
const row = raw.subarray(y * stride, (y + 1) * stride);
row[0] = 0; // filter: none
row.fill(255, 1);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // color type: truecolor RGB
const compressed = zlib.deflateSync(raw);
const split = Math.max(1, Math.floor(compressed.length / 2));
return Buffer.concat([
PNG_SIG,
chunk("IHDR", ihdr),
chunk("IDAT", compressed.subarray(0, split)),
chunk("IDAT", compressed.subarray(split)),
chunk("IEND", Buffer.alloc(0)),
]);
}
// ─── the tests ───────────────────────────────────────────────────────────────
test("providerSafePng merges a Chrome-style split IDAT stream into ONE chunk, byte-identical stream", async () => {
const chromeStyle = chromeStyleMultiIdatPng();
const inChunks = walkChunks(chromeStyle);
assert.equal(
inChunks.filter((c) => c.type === "IDAT").length,
2,
"fixture sanity: the input is the multi-IDAT shape",
);
const fixed = providerSafePng(chromeStyle);
const outChunks = walkChunks(fixed);
const outIdats = outChunks.filter((c) => c.type === "IDAT");
assert.equal(outIdats.length, 1, "exactly one IDAT chunk — the form Alibaba's decoder accepts");
// The canonical property: the merged chunk's payload is the BYTE-IDENTICAL
// zlib stream (a transparent re-split, no re-encoding at all).
assert.ok(
outIdats[0]!.data.equals(Buffer.concat(inChunks.filter((c) => c.type === "IDAT").map((c) => c.data))),
"the zlib stream is untouched",
);
// Structure preserved: same non-IDAT chunks in order, valid CRCs (walkChunks
// is dumb, so verify CRC explicitly for the merged chunk).
assert.deepEqual(
outChunks.map((c) => c.type),
["IHDR", "IDAT", "IEND"],
);
const fixedBuf = fixed;
let off = 8;
while (off < fixedBuf.length) {
const len = fixedBuf.readUInt32BE(off);
const crc = fixedBuf.readUInt32BE(off + 8 + len);
assert.equal(crc, crc32(fixedBuf.subarray(off + 4, off + 8 + len)), `valid CRC on ${fixedBuf.subarray(off + 4, off + 8).toString("ascii")}`);
off += 12 + len;
}
// And it is still a decodable image: the stream inflates to EXACTLY the
// original scanlines (filter byte 0 + white RGB pixels).
const stream = walkChunks(fixed).find((c) => c.type === "IDAT")!.data;
const stride = 1 + 4 * 3;
const expected = Buffer.alloc(stride * 4);
for (let y = 0; y < 4; y++) {
const row = expected.subarray(y * stride, (y + 1) * stride);
row[0] = 0;
row.fill(255, 1);
}
assert.ok(zlib.inflateSync(stream).equals(expected), "inflates to the original raw scanlines");
});
test("a single-IDAT PNG is already canonical: returned byte-identical (no-op)", () => {
// One-IDAT input → one IDAT out, same bytes — the normal case (e.g. the
// doctrine-gate re-render of an already-canonical PNG).
const stride = 1 + 4 * 3;
const raw = Buffer.alloc(stride * 4);
for (let y = 0; y < 4; y++) {
const row = raw.subarray(y * stride, (y + 1) * stride);
row[0] = 0;
row.fill(200, 1);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(4, 0);
ihdr.writeUInt32BE(4, 4);
ihdr[8] = 8;
ihdr[9] = 2;
const single = Buffer.concat([
PNG_SIG,
chunk("IHDR", ihdr),
chunk("IDAT", zlib.deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
assert.ok(providerSafePng(single).equals(single), "byte-identical — canonical input is untouched");
});
test("non-PNG or truncated input passes through unchanged (defensive)", () => {
const notPng = Buffer.from("definitely not a png");
assert.ok(providerSafePng(notPng).equals(notPng));
const truncated = chromeStyleMultiIdatPng().subarray(0, 20);
assert.ok(providerSafePng(truncated).equals(truncated));
});
+80 -1
View File
@@ -49,7 +49,8 @@ import { createTeacherTools } from "../src/agent/tools.ts";
import { webTools } from "../src/agent/tools/web.ts";
import { handleChatRequest } from "../src/server/chat-handler.ts";
import { recordToPiMessages } from "../src/server/pi/record-adapter.ts";
import { abortJob, claimSteers, deliverJobResult, setTurnRunner } from "../src/server/jobs.ts";
import { abortJob, claimSteers, deliverJobResult, recoverStaleJobs, setJobRespawn, setTurnRunner } from "../src/server/jobs.ts";
import { registerJobAbort, unregisterJobAbort } from "../src/agent/subagents/aborts.ts";
import { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
import { persistTurn, withMessageIds } from "../src/server/turn-service.ts";
import { chatStore } from "../src/store/chats.ts";
@@ -1120,3 +1121,81 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
assert.deepEqual(abortJob(job.id), { ok: false, reason: "not_running" });
});
});
// ─── 7. crash recovery, LIVENESS-GATED (observability-accuracy wave, 2026-09-19) ──
//
// The sweep (recoverStaleJobs) keys on the abort registry (subagents/aborts.ts
// isJobLive), not record age: a "running" record with a live registration
// belongs to a loop executing in THIS process (hung or slow — never re-run
// beside itself); without one it is a process-death orphan and recovers at
// once. The old 15-minute STALE_JOB_MS heuristic is gone — these tests prove
// immediacy by running the sweep on FRESH records.
describe("stale-job recovery sweep (liveness-gated)", () => {
/** Reset the sweep's 30s throttle — tests fire it directly, repeatedly. */
function resetSweepThrottle(): void {
const g = globalThis as { __lhWakePipeline?: { lastSweepAt: number } };
if (g.__lhWakePipeline) g.__lhWakePipeline.lastSweepAt = 0;
}
/** A FRESH "running" record (no loop registration): the orphan shape.
* Freshness is the point — the old code needed 15 minutes of age. */
function orphanJob(withResult?: ResearchResult): { chatId: string; jobId: string } {
const chat = chatStore.createChat("Test: stale sweep");
trackChat(chat.id);
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hello" }] }] as never);
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: `call-sweep-${createdJobs.length}` });
trackJob(job.id);
if (withResult) jobStore.updateJob(job.id, { result: withResult, activity: "delivering result" });
return { chatId: chat.id, jobId: job.id };
}
it("a running record with a LIVE loop registration is never recovered — however old (the double-run guard)", () => {
const { jobId } = orphanJob();
// Backdate the record well past every plausible in-flight run: age
// alone must NOT trigger recovery anymore.
jobStore.updateJob(jobId, { updatedAt: new Date(Date.now() - 30 * 60_000).toISOString() });
registerJobAbort(jobId, new AbortController()); // a loop is executing
let respawned = 0;
setJobRespawn(() => {
respawned++;
});
resetSweepThrottle();
recoverStaleJobs();
assert.equal(respawned, 0, "a live loop is provably executing — no respawn beside it");
assert.equal(jobStore.getJob(jobId)!.activity, "starting", "the record is untouched");
// Hermetic teardown: settle + unregister so later sweeps in THIS suite
// never see this fixture as a fresh orphan.
jobStore.updateJob(jobId, { status: "done", activity: "settled (test)" });
unregisterJobAbort(jobId);
});
it("a running record WITHOUT a registration is an orphan: the loop is re-spawned immediately (no age wait)", () => {
const { jobId } = orphanJob();
let respawned: string | null = null;
setJobRespawn((id) => {
respawned = id;
});
resetSweepThrottle();
recoverStaleJobs();
assert.equal(respawned, jobId, "the orphan's loop is re-spawned at once");
assert.equal(jobStore.getJob(jobId)!.activity, "recovering: re-running");
// Hermetic teardown (see above) — the respawned loop is the TEST's
// fake seam, nothing real runs.
jobStore.updateJob(jobId, { status: "done", activity: "settled (test)" });
});
it("an orphan WITH a stored result is re-delivered: the wake fires and the job flips done", async () => {
const { jobId } = orphanJob({ brief: "Done: it works.", sources: [] });
let fired = 0;
setTurnRunner(async () => {
fired++;
return 200;
});
resetSweepThrottle();
recoverStaleJobs();
await waitFor(() => fired > 0, 8000, "recovery wake fired");
await waitFor(() => jobStore.getJob(jobId)!.status !== "running", 8000, "orphan settled after re-delivery");
assert.equal(jobStore.getJob(jobId)!.status, "done");
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* Pure tests for the shared pan/zoom viewport math (components/pan-zoom.ts),
* harvested from the plan popup so the mermaid focus overlay runs the same
* fit-floor geometry: fully zoomed out IS the fit, panning locks at the
* floor (and re-centers), wheel zooms to the cursor, ceiling 4×. Pure
* function coverage — no DOM, no rendering (the overlays' live pointer
* wiring is exercised in the browser; these tests pin the math it runs).
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { FIT_ZOOM_MAX, ZOOM_MAX, fitGraphView, fitZoomFor, zoomedGraphView } from "../components/pan-zoom.ts";
describe("fitZoomFor", () => {
it("binds on the axis that runs out of room (wide box → width)", () => {
assert.equal(fitZoomFor({ w: 800, h: 400 }, { w: 400, h: 300 }), 384 / 800);
});
it("binds on height for a tall box", () => {
assert.equal(fitZoomFor({ w: 400, h: 800 }, { w: 400, h: 300 }), 284 / 800);
});
it("caps the fit at FIT_ZOOM_MAX — a tiny box must not blow up", () => {
assert.equal(fitZoomFor({ w: 50, h: 50 }, { w: 1000, h: 1000 }), FIT_ZOOM_MAX);
});
it("degenerate viewport (zero-sized) renders at 1×", () => {
assert.equal(fitZoomFor({ w: 400, h: 300 }, { w: 0, h: 0 }), 1);
});
});
describe("fitGraphView", () => {
it("centers the box at the given zoom", () => {
assert.deepEqual(fitGraphView({ w: 800, h: 400 }, { w: 400, h: 300 }, 0.5), {
x: (400 - 400) / 2,
y: (300 - 200) / 2,
zoom: 0.5,
});
});
});
describe("zoomedGraphView", () => {
const box = { w: 800, h: 400 };
const viewport = { w: 400, h: 300 };
const floor = fitZoomFor(box, viewport);
it("zooms to the focus point: the focus stays visually fixed", () => {
const out = zoomedGraphView({ x: 0, y: 0, zoom: 1 }, 1.25, floor, { x: 200, y: 150 }, box, viewport);
assert.equal(out.zoom, 1.25);
// focus.x - x_out = (focus.x - x_in) * k → the cursor anchors.
assert.equal(200 - out.x, 200 * 1.25);
assert.equal(150 - out.y, 150 * 1.25);
});
it("clamps at the floor and re-fits + RE-CENTERS (the fit is the floor)", () => {
const nearFloor = { x: -100, y: 40, zoom: floor * 1.1 };
const out = zoomedGraphView(nearFloor, 0.5, floor, { x: 200, y: 150 }, box, viewport);
assert.deepEqual(out, fitGraphView(box, viewport, floor));
});
it("clamps at ZOOM_MAX", () => {
const atMax = { x: 0, y: 0, zoom: ZOOM_MAX };
const out = zoomedGraphView(atMax, 2, floor, { x: 200, y: 150 }, box, viewport);
assert.deepEqual(out, atMax);
});
it("no-ops when the factor lands exactly on the current zoom", () => {
const view = { x: -37, y: 12, zoom: 1 };
const out = zoomedGraphView(view, 1, floor, { x: 0, y: 0 }, box, viewport);
assert.equal(out, view);
});
});
+23 -1
View File
@@ -11,7 +11,7 @@ import React from "react";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { Composer } from "../components/composer.tsx";
import { Composer, enterAction } from "../components/composer.tsx";
function render(opts: { busy?: boolean; canSend?: boolean } = {}) {
return renderToStaticMarkup(
@@ -49,3 +49,25 @@ describe("Composer", () => {
assert.ok(/aria-label="Send"[^>]*disabled|disabled[^>]*aria-label="Send"/.test(html), "send disabled");
});
});
// The Enter decision (user-decided 2026-09-19, docs/probe-teach-split.md §4):
// while the plan verdict pends, Enter must ACCEPT the plan — formerly it sent
// a plain message, leaving the verdict pending again on the next message.
// Pure function so the static-render harness can pin it (key events can't be
// simulated in renderToStaticMarkup).
describe("enterAction", () => {
it("busy stream: Enter stops — verdict or not", () => {
assert.equal(enterAction({ busy: true, verdictPending: false, canSend: true }), "stop");
assert.equal(enterAction({ busy: true, verdictPending: true, canSend: true }), "stop");
});
it("verdict pends: Enter ACCEPTS — with text (the note rides) and with an empty box alike", () => {
assert.equal(enterAction({ busy: false, verdictPending: true, canSend: true }), "accept", "typed text rides the acceptance note");
assert.equal(enterAction({ busy: false, verdictPending: true, canSend: false }), "accept", "empty box still accepts — Enter can never strand the verdict");
});
it("no verdict: send when there is text, nothing when there is not", () => {
assert.equal(enterAction({ busy: false, verdictPending: false, canSend: true }), "send");
assert.equal(enterAction({ busy: false, verdictPending: false, canSend: false }), "none");
});
});
+170
View File
@@ -0,0 +1,170 @@
/**
* Render tests for the chrome-melt wave (2026-09-19): the md-blocks
* component overrides (Streamdown `components` prop) + the session-scoped
* overlays. Idiom shared with render-markdown.test.tsx: static HTML via
* react-dom/server (no DOM, effects never run — mermaid stays at its
* skeleton, the overlays render their shells). tsx note: classic JSX
* transform — the React global shim below provides React.
*/
import React from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { MessageResponse } from "../components/ai-elements/message.tsx";
import { MermaidOverlay, SessionMermaidContext, chartSize } from "../components/session-overlay.tsx";
import { PlanPopup } from "../components/plan-block.tsx";
import { MarkdownCode, codeSource } from "../components/md-blocks.tsx";
function render(md: string): string {
return renderToStaticMarkup(<MessageResponse mode="static">{md}</MessageResponse>);
}
describe("md-blocks — code", () => {
it("inline code passes through with the house inline styling", () => {
const html = render("a `term` b");
assert.ok(html.includes('data-streamdown="inline-code"'), "inline code keeps the inline-code path");
assert.match(html, /bg-muted/);
});
it("code fences delegate to streamdown's CodeBlock with a hover copy corner", () => {
const html = render("```js\nconst x = 1;\n```");
assert.ok(html.includes('data-testid="md-code"'), "the md-blocks wrapper mounts");
assert.ok(html.includes('data-streamdown="code-block"'), "the delegated CodeBlock path is kept");
assert.ok(html.includes('data-streamdown="code-block-body"'), "shiki body renders");
assert.match(html, /aria-label="Copy code"/, "the corner copy affordance is present");
// NAMED group (group/md) — the message container is also a `group`,
// and an unnamed group-hover would light every corner in the message at
// once (owner report, 2026-09-19); only the hovered block reveals.
assert.ok(html.includes("group/md"), "the block uses the named md group");
assert.match(html, /group-hover\/md:opacity-100/, "the corner reveals via the named group only");
// No "Copied" pill — the icon flip alone conveys the copy (owner ask,
// 2026-09-19; the pill was tried and removed same day).
assert.ok(!html.includes('data-testid="md-copy-pill"'), "no Copied pill anywhere — icon flip is the feedback");
});
});
describe("md-blocks — mermaid", () => {
it("renders as the bare app-owned block (skeleton in static render), never a code block, source never leaks", () => {
const html = render("```mermaid\ngraph TD\nA --> B\n```");
assert.ok(html.includes('data-testid="md-mermaid"'), "the app-owned mermaid block mounts");
assert.ok(
!html.includes('data-streamdown="code-block"'),
"mermaid fences must not render as code blocks",
);
assert.ok(!html.includes("graph TD"), "chart source is not leaked as text");
// No chat page mounted → no overlay context → no expand button.
assert.ok(!html.includes('data-testid="md-mermaid-expand"'), "expand hidden without a session context");
});
});
describe("md-blocks — tables", () => {
it("renders as a plain prose table with the copy corner — no wrapper box, no button rows", () => {
const html = render("| A | B |\n| --- | --- |\n| 1 | 2 |");
assert.ok(html.includes('data-testid="md-table"'), "the md-blocks table wrapper mounts");
assert.ok(html.includes('data-streamdown="table"'), "the table itself renders");
assert.ok(!html.includes('data-streamdown="table-wrapper"'), "streamdown's boxed wrapper is gone");
assert.match(html, /aria-label="Copy table"/, "the corner copy affordance is present");
assert.ok(!html.match(/Fullscreen|aria-label="Download"/), "no fullscreen/download chrome on tables");
// The corner is CONTAINED in the table (top-1 right-1, not the old
// -top-3 that straddled the top outline — the 24px button at a 12px
// offset sat half over the border; owner report, 2026-09-19).
assert.match(html, /absolute top-1 right-1 z-10/, "the corner sits inside the table's top-right");
// Table text runs at the session prose size — no text-sm pin (the
// cells' own text-sm is overridden to inherit via globals.css).
assert.ok(!/border-collapse text-sm/.test(html), "the table itself pins no smaller text size");
});
});
describe("md-blocks — display math", () => {
it("the block corner reveals ONLY on its own block — named group, not the message-level group (owner report: all-at-once)", () => {
const md = "```js\nconst x = 1;\n```\n\n| A | B |\n| --- | --- |\n| 1 | 2 |";
const html = render(md);
// No UNNAMED group-hover on the corners: that selector would fire from
// the Message container's own `group` class and light every block.
assert.ok(!html.match(/\s group-hover:opacity-100/), "no unnamed group-hover on any corner");
assert.ok(html.includes('data-testid="md-table"'), "table present for the contrast");
});
it("keeps katex-display rendering and adds the TeX copy corner", () => {
const html = render("$$\n\\int_0^1 x^2\\,dx = \\tfrac{1}{3}\n$$");
assert.ok(html.includes("katex-display"), "display math still renders via KaTeX");
assert.ok(html.includes('data-testid="md-math"'), "the md-blocks math wrapper mounts");
assert.match(html, /aria-label="Copy equation source"/, "the corner copy affordance is present");
// The wrapper is BLOCK: the katex-display child is display:block, and
// an inline wrapper around it fragments — the absolute corner then has
// no clean containing block and pointer events never land (the math
// corner neither highlighted nor copied; owner report, 2026-09-19).
assert.match(html, /group\/md relative block/, "the math wrapper is a block box");
assert.match(html, /absolute top-0 right-0 z-10/, "the corner stacks above the equation");
});
});
describe("md-blocks — codeSource (pure)", () => {
it("extracts the fence source from string children and element-wrapped children", () => {
assert.equal(codeSource("const x = 1;"), "const x = 1;");
assert.equal(codeSource(<span>const x = 1;</span>), "const x = 1;");
});
});
describe("session-scoped overlays (chrome-melt wave, 2026-09-19)", () => {
it("mermaid overlay mounts inside the session area — absolute (not fixed), 80% + blur, closeable", () => {
const html = renderToStaticMarkup(<MermaidOverlay chart="graph TD\nA --> B" onClose={() => {}} />);
assert.match(html, /class="absolute inset-0[^"]*z-40/, "absolute inset-0 z-40 — scoped, not fullscreen");
assert.ok(html.includes("bg-background/80"), "80% background: the transcript ghosts through");
assert.ok(html.includes("backdrop-blur"), "blur surface");
assert.ok(!html.includes("fixed inset-0"), "never fixed — the drag bar stays uncovered");
assert.ok(html.includes('data-testid="mermaid-overlay-close"'), "the close X is present");
assert.match(html, /role="dialog"/, "dialog semantics for Esc/aria");
});
it("the plan popup is session-scoped the same way, and its chart is hidden until the first fit (no top-left flash)", () => {
const html = renderToStaticMarkup(<PlanPopup messages={[]} onClose={() => {}} />);
assert.match(html, /data-testid="plan-popup"/);
assert.match(html, /class="absolute inset-0[^"]*z-40/, "absolute inset-0 z-40 — scoped, not fullscreen");
assert.ok(html.includes("bg-background/80"), "80% background");
assert.ok(html.includes("backdrop-blur"), "blur surface");
assert.ok(!html.includes("fixed inset-0"), "never fixed — the drag bar stays uncovered");
});
it("the diagram wrapper is hidden until the first fit applies (no top-left flash — owner report, 2026-09-19)", () => {
// Covered in render-plan-block.test.tsx, which has a real tree
// fixture (an empty-messages popup renders no diagram to gate) — and
// the same gate is asserted on the mermaid overlay's wrapper here:
// settled=false at static render → opacity 0.
const html = renderToStaticMarkup(<MermaidOverlay chart="graph TD\nA --> B" onClose={() => {}} />);
// The chart itself hasn't rendered at static render (async effect) —
// the opacity gate is pinned on the plan popup's diagram wrapper in
// render-plan-block.test.tsx, which has a real tree fixture (the two
// overlays share the settled-gating pattern).
assert.ok(html.includes('data-testid="mermaid-overlay-close"'), "overlay shell renders");
});
it("chartSize reads the viewBox from a rendered svg (fallback for degenerate ones)", () => {
assert.deepEqual(
chartSize('<svg viewBox="0 0 900 700" xmlns="..."></svg>'),
{ w: 900, h: 700 },
);
assert.deepEqual(chartSize("<svg></svg>"), { w: 600, h: 400 });
});
});
describe("mermaid expand — the context seam", () => {
it("the expand button renders when a session context is mounted (static: chart stays at skeleton, so assert the seam via the provider wiring)", () => {
// The full path (skeleton → chart → hover expand) needs effects/DOM;
// what static rendering CAN pin is that a provider value flows: the
// default context is null (hidden button, covered above) and a real
// provider is the one md-blocks consumes. The overlay's open-state
// owner is chat.tsx — geometry covered by the tests above.
const opened: string[] = [];
const html = renderToStaticMarkup(
<SessionMermaidContext.Provider value={{ openMermaid: (chart) => void opened.push(chart) }}>
<div />
</SessionMermaidContext.Provider>,
);
assert.ok(html.length > 0, "provider renders");
assert.deepEqual(opened, [], "nothing opened by rendering alone");
});
});
+6
View File
@@ -193,6 +193,12 @@ describe("plan popup (live derivation)", () => {
// Arrows into not-yet-visited nodes are grayed out (owner, round 6).
assert.match(html, /marker-end="url\(#.*-dim\)"/);
assert.match(html, /live — statuses derive from your check outcomes/);
// No top-left flash: at static render settled=false, so the diagram
// wrapper paints at opacity 0 — the fit runs in an effect AFTER the
// first paint and the chart would otherwise flash at the initial
// top-left zoom:1 state for a frame (owner report, 2026-09-19;
// transition-gating alone didn't hide it).
assert.match(html, /opacity: ?0[;,}]/, "diagram hidden until the first fit applies");
});
it("renders the empty state when the session has no plan", () => {