diff --git a/app/api/jobs/route.ts b/app/api/jobs/route.ts index 0af5703..818271e 100644 --- a/app/api/jobs/route.ts +++ b/app/api/jobs/route.ts @@ -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 diff --git a/app/globals.css b/app/globals.css index f9594c6..266e00b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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) * ============================================================ */ diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx index b65a351..57db349 100644 --- a/components/activity-dock.tsx +++ b/components/activity-dock.tsx @@ -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(EMPTY_FEED); @@ -91,7 +100,7 @@ export function ActivityProvider({ children }: { children: React.ReactNode }) { if (res.ok) { const data = (await res.json()) as Partial; 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 {children}; } @@ -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; diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx index 18c823d..d9d9787 100644 --- a/components/ai-elements/message.tsx +++ b/components/ai-elements/message.tsx @@ -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 & { @@ -344,8 +341,10 @@ export type MessageResponseProps = ComponentProps; // 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} > diff --git a/components/ai-elements/reasoning.tsx b/components/ai-elements/reasoning.tsx index 15a424d..68b1315 100644 --- a/components/ai-elements/reasoning.tsx +++ b/components/ai-elements/reasoning.tsx @@ -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} > - + {transformMathDelimiters(children)} diff --git a/components/chat.tsx b/components/chat.tsx index d48a83a..9c4ee9e 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -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(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 | 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>(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>(new Set()); + useEffect(() => { + const live = new Set(); + 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 ( void sendMessage({ text: t }) }}> + {/* 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({ /> + + {/* 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 && ( + setMermaidFocus(null)} /> + )} + {planOpen && setPlanOpen(false)} />} - {planOpen && setPlanOpen(false)} />} + ); } diff --git a/components/composer.tsx b/components/composer.tsx index e2cb291..4e81e6b 100644 --- a/components/composer.tsx +++ b/components/composer.tsx @@ -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(); }} >