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