"use client"; import { createContext, Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useChat } from "@ai-sdk/react"; import { lastAssistantMessageIsCompleteWithToolCalls } from "ai"; import { createReattachingTransport } from "@/components/chat-transport"; import { SetupDialog } from "@/components/setup-dialog"; import { Hint } from "@/components/ui/hint"; import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; import { useStickToBottomContext } from "use-stick-to-bottom"; import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButton, } from "@/components/ai-elements/conversation"; import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message"; import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning"; import { Shimmer } from "@/components/ai-elements/shimmer"; import { Separator } from "@/components/ui/separator"; import { AnsweredCard } from "@/components/answered-card"; import { artifactRenderers } from "@/components/artifacts"; import { SubagentSteerCard } from "@/components/artifacts/steer-card"; import { Composer } from "@/components/composer"; import { DictationBar, useDictation } from "@/components/dictation"; import { JobsContext } from "@/components/jobs-context"; import { StatsBar } from "@/components/stats-bar"; import { useActivity } from "@/components/activity-dock"; import { isProviderKeyRefusal, mergeAdopted, type ChatInput, type ChatSettings, type ModelSlotName } 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"; import { SidebarToggle } from "@/components/sidebar"; import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TeacherUIMessage, TurnUsage } from "@/src/core"; import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider"; import { CoverageRing } from "@/components/extraction-coverage"; import { findPendingToolCall, phaseAt3, phaseTimeline, streamingToolActivity } from "@/src/core"; import { slotsConfigured, type ModelsSettings } from "@/src/core"; import { useSettingsSaved } from "@/components/settings-signal"; /** One width for everything: transcript, cards, and the bottom blob share this column. */ /** * Width tokens (wave-5 reskin, owner decision): the teaching PROSE spans the * full session width with a thin px-6 margin; the composer/question DOCK is * deliberately narrower (~46rem centered) per the reference. Same ELEMENT, * same place — width polish only. */ const COLUMN = "mx-auto w-full max-w-none px-6"; const DOCK = "mx-auto w-full max-w-[46rem] px-4"; // The DOCK container's fixed width (46rem) and the plan marginalia's // geometry (owner, plan-placement round): the bare diagram sits in the // dock band's RIGHT flank — MARGIN_GAP from the DOCK container's edge and // the same from the column's right edge. The box width is pure CSS, so // the diagram shrinks CONTINUOUSLY with the flank; MARGIN_MIN_W is only // the fade floor (owner, resize round) — below it the marginalia fades // out over the normal transition instead of popping away, and it stays // mounted while the question owns the band so widening fades it back. const DOCK_W = 46 * 16; const MARGIN_GAP = 16; const MARGIN_MIN_W = 220; // The dock overlay's gradient band (h-24) — part of the reserved tail. const GRADIENT_H = 96; // Fold thresholds (owner, scroll round 1, tuned round 3): pixel distance // from the bottom — position, not velocity. Leave the bottom → the box // folds (visual only); reach it again → it re-opens. 96 (was 64, owner // round 3) tolerates a small peek upward without folding; the dead band // vs EXPAND_DIST absorbs trackpad drift. const COLLAPSE_DIST = 96; const EXPAND_DIST = 16; /** * Server-sync channel for artifact renderers: the CENTRAL jobs poll in Chat * calls syncFromServer() when a background job settles — the wake turn's * steer + response are already persisted by then, so fresh messages replace * local state in one pull. sendText feeds the demo error-fix loop. * (contracts.ts "THE ASYNC PROTOCOL" §4.) */ export const ChatActionsContext = createContext<{ chatId: string; syncFromServer: () => Promise; /** Send a user message into the session (demo error-fix loop, W6). */ sendText: (text: string) => void; /** Opens the model setup dialog (BYOK, 2026-09-22): refused job cards * deep-link into the same SetupDialog the send interception uses. */ openSetup: () => void; } | null>(null); /** The BYOK refusal envelope from the transport's error text: the * DefaultChatTransport throws the 503 body VERBATIM as the error message, * so the machine-readable {code, missing} shape survives to here. Real * errors (any other text) read as null and keep the honest banner. * Exported pure for tests. */ export function parseModelsRefusal(message: string | undefined): ModelSlotName[] | null { if (!message) return null; try { const body = JSON.parse(message) as { code?: unknown; missing?: unknown }; if (body.code === "models_not_configured" && Array.isArray(body.missing) && body.missing.every((s) => typeof s === "string")) { return body.missing as ModelSlotName[]; } return null; } catch { return null; } } /** The provider-key refusal's transport seam (zero-keys wave, Platform's * 66dbea9): same discipline as parseModelsRefusal — the 503 body rides * the thrown error message verbatim, so the envelope's own matcher * (core, invariant-tail) decides; the wording can never drift client- * side. Exported pure for tests. */ export function parseProviderKeyRefusal(message: string | undefined): boolean { if (!message) return false; try { const body = JSON.parse(message) as { code?: unknown; error?: unknown }; return body.code === "provider_key_missing" && typeof body.error === "string" && isProviderKeyRefusal(body.error); } catch { return false; } } /** useLayoutEffect that is silent during SSR (client components still render on the server in Next). */ const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; /** A wake-pipeline steer message: user-role delivery whose metadata marks it * as a sub-agent result — rendered as an artifact card, never a bubble. */ /** The quiet system line (custom-message scheme rendering): centered, muted, * small — the transcript's voice for events that are not speech (wake * scaffolding, the learner's plan verdict). */ function SystemLine({ children, testId }: { children: React.ReactNode; testId?: string }) { return (
{children}
); } function isSubagentSteer(m: TeacherUIMessage): boolean { return (m.metadata as { subagentResult?: unknown } | undefined)?.subagentResult != null; } /** * The teaching session. All chat state lives in useChat; the server persists * the canonical transcript after every turn. * * The bottom blob is ONE persistent shell whose contents swap (chat composer ↔ * question dock ↔ collapsed bar). The shell is sticky INSIDE the scroller — * never overlapping the transcript at rest, never covering the scrollbar — * and DockOverlay animates its height on variant changes. */ export function Chat({ chatId, title, initialMessages, initialPrompt, initiallyInFlight, initialConfigured = false, }: { chatId: string; title: string; initialMessages: TeacherUIMessage[]; initialPrompt?: string; /** A teacher turn is running server-side but attached to no client * (navigated away mid-turn, returned while in flight). */ initiallyInFlight?: boolean; /** slotsConfigured(server record) — the persistent setup CTA keys on * it (BYOK, owner 2026-09-22). The server gate stays the truth; this * only decides whether the pill rides the composer. */ initialConfigured?: boolean; }) { // Reattach plumbing (background-turn runner wave): the transport holds // two stable refs — the settled terminal's callback (assigned below, // once syncFromServer exists) and the LIVE last-message id (the // continuation seam — see chat-transport.ts). Refs, not closures, so // the per-render transport recreation can never capture a stale one. const reattachSettledRef = useRef<() => void>(() => {}); const reattachLastIdRef = useRef(undefined); // THE INPUT STASH (Wave B input-events contract, owner ruling // 2026-09-26): every SDK send maps to EXACTLY ONE explicit input event — // the call site stashes the event here before invoking the SDK send, and // the transport consumes it when building the POST body ({chatId, input}; // the client transcript never rides the wire). Consumed-on-read: a send // with nothing stashed fails LOUDLY in the transport (a call-site bug — // it must never silently re-send the previous event). const pendingInputRef = useRef(null); const stashInput = (input: ChatInput) => { pendingInputRef.current = input; }; const takeInput = () => { const input = pendingInputRef.current; pendingInputRef.current = null; return input ?? undefined; }; const { messages, sendMessage, addToolOutput, setMessages, status, error, stop, clearError, resumeStream } = useChat({ id: chatId, messages: initialMessages, transport: createReattachingTransport( chatId, { onSettled: () => reattachSettledRef.current(), lastMessageId: () => reattachLastIdRef.current, }, { takeInput }, ), // After the learner answers (addToolOutput), automatically send the // continuation request so the teacher reacts to the answer. sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, }); // New chat with a topic: send the first message exactly once. const sentInitial = useRef(false); useEffect(() => { if (!sentInitial.current && initialPrompt && initialMessages.length === 0) { sentInitial.current = true; stashInput({ type: "text", text: initialPrompt }); void sendMessage({ text: initialPrompt }); } }, [initialPrompt, initialMessages.length, sendMessage]); // The reattach seam's live half: the transport compares the stream's // start id against the transcript's CURRENT last message — the // continuation rewrite keys on it (chat-transport.ts, seam 3). reattachLastIdRef.current = messages.at(-1)?.id; // Reattach on mount (stream-reattach wave): a turn that outlived its // client (navigate away mid-turn, back while in flight) is re-entered // through the SAME rendering path as a live one — resumeStream() feeds // the replay burst (instant catch-up) then the live deltas into the // SDK's own chunk pipeline. Raced settle is the transport's turn-settled // terminal: onSettled refetches the record. No once-ref guard: every // mount resumes (the deps are stable — this fires once per mount), and // React StrictMode's dev double-mount ABORTS the first attempt at // dispose — the retry must be allowed to run. useEffect(() => { if (initiallyInFlight) void resumeStream(); }, [resumeStream, initiallyInFlight]); // A turn running server-side, attached to no live view of ours (navigate // away mid-turn, or a wake turn discovered mid-session): the reattach // stream is the primary channel (resumeStream — the same rendering path // as a live turn); this flag is the FALLBACK liveness source — it gates // the composer, shows the pre-first-chunk shimmer, and drives the 1500ms // record poll that adopts at settle if the stream can't attach. const [turnInFlight, setTurnInFlight] = useState(initiallyInFlight ?? false); // Session title: click-to-rename (owner, 2026-09-17). Clicking the header // title opens edit mode with everything selected — typing replaces, arrows // move inside. Enter or a click away commits (PUT /api/chats/:id); Escape // discards. The server never bumps updatedAt on a rename (sidebar order // must not churn), and the sidebar patch is instant via the window event — // its 2.5s poll is only a backstop. const [titleText, setTitleText] = useState(title); const [renaming, setRenaming] = useState(false); const [titleDraft, setTitleDraft] = useState(""); const titleInputRef = useRef(null); const commitRename = useCallback( async (next: string) => { setRenaming(false); const t = next.trim(); if (t === "" || t === titleText) return; // empty commit = discard try { const res = await fetch(`/api/chats/${chatId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: t }), }); if (!res.ok) throw new Error(`rename failed: ${res.status}`); setTitleText(t); window.dispatchEvent( new CustomEvent("lh:chat-renamed", { detail: { id: chatId, title: t } }), ); } catch (err) { console.error("rename failed:", err); // keep the old title } }, [chatId, titleText], ); // Select-all when edit mode opens: the click means "replace", so the // whole title highlights on entry (owner ask, 2026-09-17). useEffect(() => { if (renaming) { const el = titleInputRef.current; if (el) { el.focus(); el.select(); } } }, [renaming]); // ── Keyboard seams (shortcut wave A, 2026-09-17) ── // F2 opens the title rename (the chat header owns the editing state). useEffect(() => { const onRename = () => { setTitleDraft(titleText); setRenaming(true); }; window.addEventListener("lh:kbd-rename-session", onRename); return () => window.removeEventListener("lh:kbd-rename-session", onRename); }, [titleText]); // The OTHER rename surface (wave 6: the sidebar's inline rename): its // commit dispatches the same lh:chat-renamed event this header's rename // does — if it renamed THIS chat, patch the header title in place (the // event is the single source of truth; the header is one more listener). useEffect(() => { const onRenamed = (e: Event) => { const { id, title } = (e as CustomEvent<{ id: string; title: string }>).detail; if (id === chatId) setTitleText((prev) => (title !== prev ? title : prev)); }; window.addEventListener("lh:chat-renamed", onRenamed); return () => window.removeEventListener("lh:chat-renamed", onRenamed); }, [chatId]); // The Esc chain's last link: abort the turn. The background runner // decoupled turns from views, so the abort is ALWAYS the remote kill // (POST /api/activity/abort — any in-flight turn dies server-side, our // view renders the aborted-partial close when the finish arrives). A // LOCAL view also detaches first — stop() settles the client instantly // and keeps the partial; the server-side partial persists via the // abort's own onFinish. Inert when nothing runs (busyRef gate). const abortInFlightTurn = useCallback(() => { if (localStreamRef.current) stop(); void fetch("/api/activity/abort", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ kind: "turn", id: chatId }), }).catch(() => { /* transient — the turn settles on its own; the record poll adopts */ }); // 409 not_running is the benign raced-settle: the stream's finish // already settled the view (or the poll fallback adopts) — nothing owed. }, [chatId, stop]); useEffect(() => { const onAbort = () => { if (busyRef.current) abortInFlightTurn(); }; window.addEventListener("lh:kbd-abort-turn", onAbort); return () => window.removeEventListener("lh:kbd-abort-turn", onAbort); }, [abortInFlightTurn]); // Mount continuation: if the stored transcript ends with a fulfilled tool // (a job result landed while no client was open), resume the lesson — the // SDK's sendAutomaticallyWhen only fires inside addToolOutput, so reloads // need this nudge. Once per mount; the turn that follows ends in text (or // a pending question), so no loop. NEVER while a turn is already running // server-side — that would double-run the teacher (the server would 409 // it; the poll below resumes instead). const mountContinuation = useRef(false); useEffect(() => { if (mountContinuation.current) return; mountContinuation.current = true; if ( !turnInFlight && status === "ready" && messages.length > 0 && lastAssistantMessageIsCompleteWithToolCalls({ messages }) ) { stashInput({ type: "continue" }); void sendMessage(); } // mount-only by design (turnInFlight read at initial value) }, []); const pending = findPendingToolCall(messages); const localStream = status === "submitted" || status === "streaming"; const busy = localStream || turnInFlight; const busyRef = useRef(busy); busyRef.current = busy; // The attach handoff (stream-reattach wave): the moment a resumed // stream takes over the rendering — the SDK left "ready", so // submitted/streaming means chunks are flowing — localStream covers // busy/shimmer/gates on its own. Keeping turnInFlight set would only // double-render the shimmer and keep the 1500ms fallback poll fighting // the live stream it was a fallback FOR. useEffect(() => { if (turnInFlight && (status === "submitted" || status === "streaming")) setTurnInFlight(false); }, [status, turnInFlight]); // Mid-session discovery (jobs poll, handoff watch): a server-side turn // exists that none of our views holds — attach to it through the same // resume path as mount reattach (live rendering, instant catch-up). The // flag stays as the fallback until the stream attaches (watcher above); // concurrent resume calls are SDK-guarded (a newer one aborts the older // request), so discovery ticks re-firing this are harmless. const turnInFlightRef = useRef(turnInFlight); turnInFlightRef.current = turnInFlight; const attachToRunningTurn = useCallback(() => { setTurnInFlight(true); void resumeStream(); }, [resumeStream]); // Teach-phase check (W5, vision §2): a pending question that lands once a // plan node is OCCUPIED (phaseAt3 "teach") starts collapsed behind a // "Check your understanding" reveal button; probe- and plan-phase // questions auto-open as today. A pending question can never precede its // phase's seam in the transcript (the teacher's turn ends at the question // call), so the session's current phase IS the question's phase. const requiresReveal = pending !== null && phaseAt3(messages) === "teach"; // The phase at every message (one linear fold): the transcript draws a // color-coded dotted divider at each boundary; the header chip reads the // session's current phase. const phases = useMemo(() => phaseTimeline(messages), [messages]); // A tool call still streaming its input: the teacher is mid-activity. The // label names the ACTUAL tool (a long grep used to shimmer as “Writing a // question…” for minutes — a part stays input-streaming through its whole // execution, see streamingToolActivity); shown as the transcript shimmer, // exactly like the thinking indicator. const streamingActivity = !pending ? streamingToolActivity(messages) : null; const [text, setText] = useState(""); // The BYOK setup flow (owner, 2026-09-22): the persistent CTA and the // refusal interception share one SetupDialog mount. `configured` is // seeded server-side (slotsConfigured on the record) and flips on Save. const [configured, setConfigured] = useState(initialConfigured); // The zero-keys truth (zero-keys wave): no provider key = the pill // takes precedence over the models CTA (keys come first in the chain). // Reads once on mount, re-reads on the "keys" signal. const zeroKeys = useZeroKeys(); // Cross-surface freshness (fix wave): another surface's models write — // setup save, composer pick, a settings write — re-reads the record so // the setup CTA appears/disappears live (it used to lag until reload). useSettingsSaved((section) => { if (section !== "models") return; fetch("/api/settings") .then((r) => r.json() as Promise<{ settings?: { models?: ModelsSettings } | null } | null>) .then((body) => setConfigured(slotsConfigured(body?.settings?.models))) .catch(() => {}); }); const [setupOpen, setSetupOpen] = useState(false); /** The last explicitly-sent draft (composer send, verdict note): the * refusal flow restores it into the composer and Save replays it. * Auto-continuations (probe answers) never set it — their refusal * opens the dialog with no replay. */ const draftRef = useRef(null); // THE BYOK REFUSAL (owner, 2026-09-22): a send that lands the server's // 503 models_not_configured envelope renders as the SETUP FLOW, never // the error banner. The gate refuses BEFORE the write-ahead persist, // so the server record is the truth minus the refused send — the // optimistic message is an orphan that goes HOME: refetch the record, // drop the orphan, put the draft back in the composer, open the // dialog (the prefilled tiers carry their picks, the unpinned ones the // "needed" marks). Esc keeps the draft; Save replays it. useEffect(() => { if (!error) return; const missing = parseModelsRefusal(error.message); // The key-refusal twin (zero-keys wave): a pinned model whose // provider key is gone gets the SAME treatment — revert-draft and // open setup (the dialog derives to its first-key stage on its own). const keyRefused = parseProviderKeyRefusal(error.message); if (!missing && !keyRefused) return; // a real error keeps the honest banner // ORDER MATTERS: clearError() flips this effect's own dep (`error`) // and would cancel the in-flight work below — so the banner dismiss, // the draft restore, and the dialog open all land TOGETHER after the // record refetch (the honest truth minus the refused send). let cancelled = false; void (async () => { const res = await fetch(`/api/chats/${encodeURIComponent(chatId)}`); if (!cancelled && res.ok) { const record = (await res.json().catch(() => null)) as { messages?: TeacherUIMessage[] } | null; if (Array.isArray(record?.messages)) setMessages(record.messages); } if (cancelled) return; if (draftRef.current !== null) { setText(draftRef.current); draftRef.current = null; } clearError(); setSetupOpen(true); })(); return () => { cancelled = true; }; // (setMessages/clearError are stable per useChat instance — the // dep pair [error, chatId] is what the flow actually keys on.) }, [error, chatId]); // Voice dictation (STT wave, 2026-09-17): finals append at the end of // the current text (the page owns the separator, dictate-flush style); // interims ghost in the stats bar's middle slot next to the meter. const dictation = useDictation({ append: (t) => setText((v) => (v ? v.replace(/\s+$/, "") + " " + t : t)), }); // Jobs never gate the composer (contracts.ts "THE ASYNC PROTOCOL" §4): job // tools resolve with an ack mid-turn, so the learner can interject while // sub-agents run — a feature, not a race: the wake pipeline queues steers // until the current turn ends. Only a QUESTION pending or a turn in // flight (client or server-side wake turn) gates sending. const canSend = text.trim().length > 0 && !pending && !turnInFlight; const syncFromServer = useCallback(async () => { const res = await fetch(`/api/chats/${chatId}`); if (!res.ok) return; const record = (await res.json()) as { messages?: TeacherUIMessage[]; turns?: TurnUsage[] }; if (Array.isArray(record.turns)) setTurns(record.turns); if (!Array.isArray(record.messages)) return; setMessages(record.messages); // setMessages does NOT fire sendAutomaticallyWhen (the SDK only checks // it inside addToolOutput) — without this the teacher would stay silent // after a job result lands. sendMessage() with no args re-requests with // the current list: the continuation. (contracts.ts protocol §4.) if ( status === "ready" && lastAssistantMessageIsCompleteWithToolCalls({ messages: record.messages }) ) { stashInput({ type: "continue" }); void sendMessage(); } }, [chatId, setMessages, sendMessage, status]); // The settled terminal's other half (stream-reattach wave): the // transport resolved turn-settled — no turn running, the record is // current. Drop the fallback busy state and adopt the record (its // continuation check fires the owed next turn, exactly as the poll's // settle branch always did). reattachSettledRef.current = () => { setTurnInFlight(false); void syncFromServer(); }; // ── Append-only record adoption (owner ground rules, 2026-09-15) ── // Content delivered into the record server-side — sub-agent // artifacts, abort notes — must become visible the // moment it persists, mid-turn included: it sits between messages, and // nothing about a running turn should hide it. The full snapshot sync // mid-stream is forbidden (it breaks the SDK's in-place continuation // merge — the stale-snapshot lesson), so adoption here is APPEND-ONLY // (core mergeAdopted): insert the record's new ids in their canonical // order, never rewrite a local one, keep the in-flight tail last. The // full syncFromServer stays the net at idle (reorder, plan blocks, turns). const messagesRef = useRef(messages); messagesRef.current = messages; const adoptAppendOnly = useCallback(async () => { try { const res = await fetch(`/api/chats/${chatId}`); if (!res.ok) return; const record = (await res.json()) as { messages?: TeacherUIMessage[] }; if (!Array.isArray(record.messages)) return; const merged = mergeAdopted(messagesRef.current, record.messages); if (merged) setMessages(merged); } catch { /* transient — the idle full sync is the net */ } }, [chatId, setMessages]); // Turn-start window: the server inserts claimed steers right after our // outgoing message, BEFORE the stream produces anything — so a bounded, // self-terminating window of three record fetches (400ms/1s/2s) catches // the insert and shows the marker during the turn, then stops. No // standing poll — the window exists only for these seconds. The same // instant nudges the activity feed: extraction and sub-agent jobs // start at send (server-side), and the dock must say so immediately. const { refresh: refreshActivity } = useActivity(); const turnStartStatusRef = useRef(status); // The window must survive the submitted→streaming flip (the stream starts // in well under 400ms), so the timers are guarded by this ref — dropped at // unmount, left alone otherwise: the window self-terminates after 2s. const adoptionWindowOpenRef = useRef(false); const turnStartTimerRefs = useRef[]>([]); useEffect(() => { const was = turnStartStatusRef.current; turnStartStatusRef.current = status; if (was === "submitted" || status !== "submitted") return; // A turn just started (status flipped ready → submitted): open the // bounded adoption window and nudge the dock's feed. refreshActivity(); adoptionWindowOpenRef.current = true; const timers = [400, 1000, 2000].map((ms) => setTimeout(() => { if (adoptionWindowOpenRef.current) { void adoptAppendOnly(); // Turn-start nudge re-fire (accuracy wave): extraction and // sub-agent jobs start 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 // flip, which would cancel the window at submitted→streaming. timers.forEach((t) => { turnStartTimerRefs.current.push(t); }); }, [status, adoptAppendOnly, refreshActivity]); useEffect(() => { const timers = turnStartTimerRefs; return () => { adoptionWindowOpenRef.current = false; timers.current.forEach(clearTimeout); timers.current = []; }; }, []); // 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 coverage ring's refresh chain (extraction-UX redesign): bumped at // turn settle — the moment this session's uncovered mass changes. const [coverageTick, setCoverageTick] = useState(0); // 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]); // ── The plan verdict gate (docs/probe-teach-split.md §4) ── // The plan has been presented (a tree exists) and the session still sits in // the planning phase → the composer's send square becomes the stacked // Accept / Decline buttons. Acceptance is the explicit plan→teach seam // (a data-plan-accepted part on the message); decline asks for revision. const planVerdict = useMemo( () => phaseAt3(messages) === "plan" && planTree !== null, [messages, planTree], ); const sendVerdict = useCallback( (accepted: boolean) => { const note = text.trim(); if (note) draftRef.current = note; // refusal flow: the note replays as text setText(""); // TWO REAL MESSAGES, ONE TURN (the custom-message scheme, user-decided // 2026-09-12): a typed note is the learner's own user message, and the // verdict is its own custom message — marker part ONLY, no human- // authored text (the model-facing wording lives in toLlmMessages, // the native convertToLlm seam; nothing fake is persisted as speech). // setMessages + sendMessage() (no args) fires ONE request carrying // both — the same single-turn mechanism syncFromServer already uses, // so no 409 dance and no double teacher turn. const stamp = Date.now().toString(36); const rnd = crypto.randomUUID().slice(0, 8); const appended: TeacherUIMessage[] = []; if (note) { appended.push({ id: `msg_${stamp}_${rnd}`, role: "user", parts: [{ type: "text", text: note }], }); } appended.push({ id: `verdict_${stamp}_${rnd}`, role: "user", parts: [ { type: "data-verdict", id: `vd_${stamp}_${rnd}`, data: { accepted, at: new Date().toISOString() }, }, ], }); setMessages([...messages, ...appended]); // The event (Wave B): the server appends the note (its own user // message) + the verdict marker natively — the optimistic ids above // are local render only; the settle sync lands the canon. stashInput({ type: "verdict", accepted, note: note || undefined }); void sendMessage(); }, [text, messages, setMessages, sendMessage], ); // ── Session usage (wave-4 stats bar) ── // The money channel: the record's turns log, folded client-side by // core/prices.ts. Seeded once on mount (the page doesn't carry turns), // refreshed when the local stream settles (each turn appends to the log // server-side), and kept fresh by syncFromServer whenever it already // fetches the record. No turns yet → the bar renders "—" everywhere. const [turns, setTurns] = useState([]); // The per-chat model control state (model-picker writes via PUT; the // record is the source of truth, lifted here so the stats bar re-renders). // The dock's reserved tail height (DockOverlay reports it; the spacer at // the end of the transcript renders it). Lives here because the spacer is // inside the scroller while the dock overlays OUTSIDE it — two parents. // 208 ≈ gradient(96) + composer block(~112): the SSR/pre-hydration // guess so the static paint has a sane tail. The first layout effect // replaces it with the measured truth before paint. const [dockReserve, setDockReserve] = useState(208); const syncTurns = useCallback(async () => { try { const res = await fetch(`/api/chats/${chatId}`); if (!res.ok) return; const record = (await res.json()) as { turns?: TurnUsage[] }; if (Array.isArray(record.turns)) setTurns(record.turns); } catch { // transient blip: stats stay on the last known figures } }, [chatId]); useEffect(() => { void syncTurns(); }, [syncTurns]); // After the learner's own turn ends the log has a fresh entry — and the // Post-turn adoption + the HANDOFF WATCH. Two things can trail our own // stream's end: (1) the server's persist/endTurn — milliseconds, and the // record is only complete once not-in-flight (plan blocks attach at // persist); (2) a WAKE TURN — the probe→teacher handoff fires // ~0.75–1.2s later (the pump's grace), and NO jobs poll is running to // notice it: probe-phase sessions have zero job traffic, so the jobs // poll is switched off and the open client never learns the teacher's // turn exists (the freeze the user hit 2026-09-11 — the sidebar saw // busy→idle while the session sat frozen). So: adopt at the first idle // read as before, then keep watching briefly — if turnInFlight flips // true, hand off to the wake-adoption poll below (busy shimmer, adopt // at finish). const prevStatusRef = useRef(status); useEffect(() => { if (prevStatusRef.current !== "ready" && status === "ready") { // immediate: turns-only refetch — the stats bar updates even while // the server's persist/endTurn lags the client by milliseconds void syncTurns(); // The coverage ring re-reads at turn settle too (extraction-UX // redesign, 2026-09-24): this turn's messages just joined the // uncovered mass; the ring's own running-poll handles runs. setCoverageTick((t) => t + 1); // 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; const WATCH_ATTEMPTS = 8; // 8 × 500ms: persist lag + the ~1.2s handoff const adopt = async (attempt: number) => { if (cancelled) return; try { const res = await fetch(`/api/chats/${chatId}`); if (res.ok) { const record = (await res.json()) as { turnInFlight?: boolean }; if (!adopted) { // phase 1: wait out our own turn's persist lag, then adopt once if (record.turnInFlight !== true) { adopted = true; void syncFromServer(); } } else if (record.turnInFlight === true) { // phase 2 (the watch): a wake turn started — the handoff. // Attach to it live (stream-reattach wave): the same // resume path as mount reattach; the record poll is // the fallback while it connects. attachToRunningTurn(); return; } } } catch { /* transient — keep polling */ } if (attempt < WATCH_ATTEMPTS && !cancelled) timer = setTimeout(() => void adopt(attempt + 1), 500); }; void adopt(0); return () => { cancelled = true; if (timer) clearTimeout(timer); nudgeTimers.forEach(clearTimeout); }; } prevStatusRef.current = status; }, [status, chatId, syncFromServer, syncTurns, refreshActivity, attachToRunningTurn]); // A stream that died MID-FLIGHT (network): the runner decoupled the turn // from its view, so the server turn runs ON behind the error banner — // re-enter the in-flight watch (the 1500ms fallback poll adopts at // settle) instead of leaving the session blind until the next send's // 409 merge-retry heals it. One record check per error transition. const prevErrorStatusRef = useRef(status); useEffect(() => { const was = prevErrorStatusRef.current; prevErrorStatusRef.current = status; if (was === "error" || status !== "error") return; let cancelled = false; void (async () => { try { const res = await fetch(`/api/chats/${chatId}`); if (!res.ok || cancelled) return; const record = (await res.json()) as { turnInFlight?: boolean }; if (cancelled) return; if (record.turnInFlight === true) setTurnInFlight(true); else void syncFromServer(); } catch { /* transient — the banner stands; the next send's merge-retry heals */ } })(); return () => { cancelled = true; }; }, [status, chatId, syncFromServer]); // ── Central job observability (contracts.ts "THE ASYNC PROTOCOL" §4) ── // ONE poll of /api/jobs drives every job surface (transcript dispatch // cards, the sub-agents panel) and re-syncs the transcript exactly once // when a job settles. A settle no longer implies the record is complete: // CONTINUATION COALESCING flips the job done at claim time — while the // learner's own answer turn is still streaming (and a settle can race a // second job's wake turn). Syncing mid-turn would swap in a stale snapshot // mid-stream, which breaks the SDK's in-place continuation merge (duplicate // message ids). So: a settle during a busy turn defers the sync; the next // idle poll tick adopts the finished record. Poll fast while jobs run, // slow otherwise (new-dispatch discovery), and not at all when this // session has never dispatched a job. const [jobs, setJobs] = useState([]); const jobPartCount = useMemo( () => messages.reduce( (n, m) => n + m.parts.filter( // sources v0 — "tool-pdf" added to the job-part enumerations // (mechanical, UI-stream review at merge): poll cadence + // dispatch nudge + settle-sync all key on these parts. (p) => p.type === "tool-research" || p.type === "tool-demo" || p.type === "tool-visual" || p.type === "tool-pdf", ).length, 0, ), [messages], ); const anyRunning = jobs.some((j) => j.status === "running"); const pollMs = jobPartCount === 0 && jobs.length === 0 ? 0 : anyRunning ? 1000 : 4000; const localStreamRef = useRef(false); localStreamRef.current = localStream; 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.type === "tool-pdf") && // sources v0 — see the poll-cadence enumeration above 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 { const res = await fetch(`/api/jobs?chatId=${encodeURIComponent(chatId)}`); if (!res.ok) return; 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. // Ground rule (2026-09-15): delivered content shows // immediately anyway — adopt append-only NOW; the idle // sync still settles the record properly. if (localStreamRef.current || data.turnInFlight === true) { pendingSyncRef.current = true; void adoptAppendOnly(); } else { 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 && data.turnInFlight !== true ) { pendingSyncRef.current = false; void syncFromServer(); } setJobs(list); if (nudgeActivity) refreshActivity(); // Wake-turn adoption: a server-side teacher turn with no client request // of ours (sub-agent delivery) — attach to it LIVE through the same // reattach path (instant catch-up + deltas); while we hold a local // stream the turn is already ours — nothing to attach to. if (data.turnInFlight === true && !localStreamRef.current) attachToRunningTurn(); } catch { /* transient fetch failure — keep polling */ } }; void poll(); const timer = setInterval(poll, pollMs); return () => { cancelled = true; clearInterval(timer); }; }, [pollMs, jobPartCount, chatId, syncFromServer, adoptAppendOnly, refreshActivity, attachToRunningTurn]); // The FALLBACK liveness poll (stream-reattach wave): runs only while the // reattach stream couldn't take over — the pre-attach window, a failed // resume, or a resumed stream that died mid-flight. Polls the record // until the orphaned turn finishes (turnInFlight flips false server-side // in onFinish, AFTER persist), then adopts the completed transcript — // the syncFromServer above already runs the job-continuation check. // Until then the session reads as busy: pre-first-chunk shimmer, // composer gated. Steer content shows NOW via append-only adoption. useEffect(() => { if (!turnInFlight) return; let cancelled = false; let timer: ReturnType | undefined; const poll = async () => { if (cancelled) return; try { const res = await fetch(`/api/chats/${chatId}`); if (res.ok) { const record = (await res.json()) as { turnInFlight?: boolean; messages?: TeacherUIMessage[]; }; // Ground rule (2026-09-15): steer content persisted by the // wake shows NOW, at the wake turn's start — not at its // finish. Append-only is safe here: no local stream runs // under a server-side wake turn. if (Array.isArray(record.messages)) { const merged = mergeAdopted(messagesRef.current, record.messages); if (merged) setMessages(merged); } if (!record.turnInFlight) { setTurnInFlight(false); // Heal a stream-death banner (the failed-attach fallback): the // record is the truth and the truth just landed. if (status === "error") clearError(); void syncFromServer(); return; } } } catch { /* transient network hiccup — keep polling */ } if (!cancelled) timer = setTimeout(poll, 1500); }; timer = setTimeout(poll, 1000); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, [turnInFlight, chatId, syncFromServer, status, clearError]); const onSend = () => { const t = text.trim(); if (!t || busy || pending) return; draftRef.current = t; // the refusal flow's preserve-and-replay seam setText(""); stashInput({ type: "text", text: t }); void sendMessage({ text: t }); }; return ( { stashInput({ type: "text", text: t }); void sendMessage({ text: t }); }, openSetup: () => setSetupOpen(true), }} > {/* The session surface: the transcript column (header + scroller) and the plan-tree panel as full-height SIBLINGS — the panel spans top to bottom exactly like the left sidebar, and the header (name + phase chip) spans from the left sidebar's edge to the panel's edge. The dock overlays the column bottom (outside the scroller); a spacer in the transcript reserves its height. */}
{/* The top bar is its own block (owner, top-fade round 4): in flow above the scroller on the plain page background — no gradient. Its bottom outline (border-b border-border) matches the one that separates the left sidebar — same width, same color (owner, r5). h-15 (60px), not py-2: the toggle (a size-9 icon button) renders only while the sidebar is CLOSED — an auto-height row would collapse when it disappears and slide the title + chip up. 60px is an ALIGNMENT, not just a size: it equals the sidebar's LH wordmark band, so the header's border-b continues that band's bottom edge as one line across the app (owner, round 6). */}
{ // Escape hitbox: any mousedown outside the input itself blurs // it, running the existing onBlur commit path. The input is // shrink-to-fit (field-sizing), so the rest of the bar is // this spacer + inert chips — all click-to-exit (owner ask). if (renaming && e.target !== titleInputRef.current) titleInputRef.current?.blur(); }} > {renaming ? ( <> setTitleDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void commitRename(titleDraft); // stopPropagation claims the Esc (keyboard wave A): // closing rename must not also abort a turn. else if (e.key === "Escape") { e.stopPropagation(); setRenaming(false); } }} onBlur={() => void commitRename(titleDraft)} spellCheck={false} aria-label="Session title" title="Rename this session" className="lh-no-drag min-w-[8ch] max-w-[70%] shrink-0 bg-transparent font-title text-3xl tracking-tight caret-current outline-none [field-sizing:content]" /> {/* flex-1 spacer during rename too, so the chips stay at the right edge; clicks here commit via the header mousedown handler. */}
{messages.length === 0 && !busy && ( )} {messages.map((m, mi) => m.role === "user" && isSubagentSteer(m) ? ( // Sub-agent delivery (wake-pipeline steer): NO message bubble — the // artifact card sits in the transcript column directly, full width // and centered like the teacher's artifacts (contracts.ts "THE ASYNC // PROTOCOL" §4). Key fallback: legacy transcripts (pre id-hygiene) // can still hold empty ids — the index keeps keys unique for them. ) : m.role === "user" && m.parts.some((p) => p.type === "data-probe-handoff") ? ( // The probe→teach handoff wake (custom-message scheme): quiet // system line, never a user bubble — the wake is scaffolding, // the "Planning" divider marks the seam, and the model-facing // instruction lives in the adapter. Preparing your plan… ) : m.role === "user" && m.parts.some((p) => p.type === "data-verdict") ? ( // The learner's plan verdict (custom-message scheme): its own // message, the quiet marker — the accept is the phase seam, a // decline asks for revision. Never a user bubble: the model- // facing wording is the adapter's, and this is the learner's // ACT, not their words (a typed note, if any, rendered above // as its own real message). p.type === "data-verdict") as | { data?: { accepted?: boolean } } | undefined)?.data?.accepted ? "verdict-accepted" : "verdict-declined" } > {(m.parts.find((p) => p.type === "data-verdict") as | { data?: { accepted?: boolean } } | undefined)?.data?.accepted ? "✓ Plan accepted" : "↻ Plan revision requested"} ) : ( {mi > 0 && phases[mi] !== phases[mi - 1] && ( )} {m.role === "user" ? ( // User text renders plain: the model's LaTeX pipeline is not // applied to learner-authored text ($ stays $, no surprises).
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
) : ( m.parts.map((part, pi) => { const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1; switch (part.type) { case "text": return {part.text}; case "reasoning": return ( {part.text} ); case "step-start": return pi > 0 ? : null; case "data-plan-block": // The plan in the chat (wave 4): a frozen snapshot // block — rendered as carried, never re-derived. return ( setPlanOpen(true)} /> ); case "tool-quiz": case "tool-ask_user_question": { // Pending/streaming questions live in the dock, not the // transcript; answered ones render as graded records. if (part.state === "output-available") { return ; } return null; } default: { // Artifact extension point (contracts.ts): job // parts and future artifacts render via the // registry — add renderers there, not here. if (part.type.startsWith("tool-")) { const Renderer = artifactRenderers[part.type.slice("tool-".length)]; if (Renderer) return ; } return null; } } }) )}
))} {(status === "submitted" || turnInFlight) && Thinking…} {streamingActivity && {streamingActivity}} {error && (
Something went wrong: {error.message}
)}
{/* The dock's reserved tail (DockOverlay reports its height): keeps at-rest transcript content scrolling fully clear of the overlay — the flow-space replacement for the old sticky dock. Zero only pre-measurement (the first layout effect reports before paint). */}
{/* The dock: an overlay sibling of the scroller (the use-stick-to-bottom pattern). The scroll-down button lives INSIDE the overlay now (owner refinement round, 2026-09-24): anchored to the dock block's top edge, so it rides every height morph (typing, a question growing the band, the fold) and NEVER overlaps the composer. */} } onOpenPlan={() => setPlanOpen(true)} requiresReveal={requiresReveal} phase={phases.at(-1) ?? "probe"} turns={turns} onReserve={setDockReserve} dictationSlot={} dictationCancel={dictation.cancel} setupCta={ zeroKeys ? ( setSetupOpen(true)} /> ) : ( !configured && (
) ) } composer={ sendVerdict(true), onDecline: () => sendVerdict(false), } : undefined } /> } onSubmitAnswer={(output) => { if (!pending) return; void addToolOutput({ tool: pending.tool as "quiz" | "ask_user_question", toolCallId: pending.toolCallId, output, }); }} /> {/* 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)} />}
{/* The BYOK setup flow (owner, 2026-09-22): one mount serves the persistent CTA, the refused-send interception, and the job cards' deep-link. Save replays the preserved draft automatically; Esc keeps it in the composer (the dialog is closable by design — the gate is the server, not this modal). */} setSetupOpen(false)} onDone={() => { setSetupOpen(false); setConfigured(true); // Draft-preserving replay (owner ruling): the draft went home // into the composer at refusal time; Save sends it onward. const t = text.trim(); if (t) { setText(""); draftRef.current = t; stashInput({ type: "text", text: t }); void sendMessage({ text: t }); } }} /> ); } /** * The floating blob: ONE persistent shell, OVERLAID at the bottom of the * column (absolute — a sibling of the scroller, the use-stick-to-bottom * pattern; it replaced the old in-flow sticky dock, which died of the * fold-latency problem below). Contents swap between the chat composer and * the question dock; shell height morphs between variants (WAAPI, quick). A * spacer at the end of the transcript (rendered by ChatView from onReserve * reports) holds the dock's flow-space, so at-rest content scrolls fully * clear of the overlay. * * Why an overlay (owner, scroll round 1): an in-flow dock IS scroll geometry * — folding it shrinks the scroll content, so the fold had to wait until the * learner was a full dock-height past the bottom (any closer and the browser * clamp pinned them back, re-expanding the box: the oscillation). Decoupled * from the flow, the fold is VISUAL ONLY: it fires as soon as the learner * leaves the bottom, and the reserved tail only ever GROWS while a question * is open (a shrinking tail would reintroduce the clamp). * * Collapse: scroll up past COLLAPSE_DIST and the dock folds into a slim bar; * click it (or scroll back to the bottom) to re-expand. The question form * stays MOUNTED while collapsed (hidden, not unmounted) so a half-made * selection survives. */ /** * How a teach-phase question arrives (owner ruling, 2026-09-25): collapsed * behind the reveal button when it follows the composer — the learner * reads the teaching first — but OPEN when it follows another question * (a queued parallel check): the reading already happened, so there is * nothing left to wait for and the scroll height it already had is the * honest height. */ export function revealArrival(wasQuestion: boolean): { expanded: boolean; revealed: boolean } { return wasQuestion ? { expanded: true, revealed: true } : { expanded: false, revealed: false }; } function DockOverlay({ pending, requiresReveal, phase, turns, composer, onSubmitAnswer, chatId, onReserve, plan, onOpenPlan, dictationSlot, dictationCancel, setupCta, }: { pending: ReturnType; /** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */ requiresReveal: boolean; /** Session phase at the pending question — scales the thinking field. */ phase: Phase3; /** The session's usage log — the stats bar beneath the composer folds it. */ turns: TurnUsage[]; composer: React.ReactNode; onSubmitAnswer: (output: QuestionAnswerData) => void; /** The model control's pass-through (the stats bar hosts it). */ chatId: string; /** Reports the dock's settled height (block + gradient) — ChatView's * dockReserve state feeds the transcript's spacer from it. */ onReserve: (h: number) => void; /** The plan marginalia's content (the bare diagram) — rendered only * while a question owns the band. Null until a plan exists. */ plan?: React.ReactNode; /** Opens the full-DAG popup (the marginalia + chip share it). */ onOpenPlan: () => void; /** The dictation strip (STT wave): rendered in the stats bar's middle * slot — the mic button + live meter (components/dictation.tsx). */ dictationSlot?: React.ReactNode; /** Esc in a pending question's field stops dictation (the strip is the * stats bar's; the stop belongs to the page-owned hook, threaded down). */ dictationCancel?: () => void; /** The persistent setup CTA (BYOK, owner 2026-09-22): the same pill * home and /chat/new carry, above the composer while slots are unset. */ setupCta?: React.ReactNode; }) { const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext(); // Teach-phase mount (session reload with a pending check): the dock starts // as the collapsed invitation too — the check must not pop open on reload. const [expanded, setExpanded] = useState(!requiresReveal); // Reveal state (W5): false until the learner clicks the invitation. While // false, the scroll machine must NOT auto-expand at the bottom — the // learner reads the teaching first and opens the check when ready. Once // revealed, the normal collapse machine rules apply untouched. const [revealed, setRevealed] = useState(false); const revealPending = requiresReveal && !revealed; const pendingId = pending?.toolCallId ?? null; const initialPendingRef = useRef(pendingId); // Teach-question ARRIVAL state, applied during render — BEFORE the // first paint (owner, scroll-honesty round 2026-09-25). Until now the // stale expanded=true left by the previous question painted one frame // of the OPEN question: the dock measured itself tall, permanently // reserved that height, and the collapse after the paint could never // give it back (fold immunity) — so a collapsed teach question scrolled // the transcript as if it had opened. Resetting here means the paint // measures the bar, the reserve keeps the composer's height, and the // only motion is the teaching text itself. const prevPendingIdRef = useRef(pendingId); if (pendingId !== prevPendingIdRef.current) { const wasQuestion = prevPendingIdRef.current !== null; prevPendingIdRef.current = pendingId; if (pendingId && requiresReveal) { const arrival = revealArrival(wasQuestion); setExpanded(arrival.expanded); setRevealed(arrival.revealed); } } // New question MID-SESSION: open expanded if the learner is following at // the bottom (scroll to reveal it); reading history → arrive as the bar. // Teach-phase arrivals are handled above, during render. On session // mount the dock simply starts in its initial state (expanded for // probe, invitation for teach) — no scroll, no morph. useEffect(() => { if (!pendingId || pendingId === initialPendingRef.current) return; if (!requiresReveal) { setExpanded(isAtBottom); if (isAtBottom) void scrollToBottom(); } // fire only when a NEW question lands }, [pendingId]); // ── Fold machine (position, not velocity — owner, scroll round 1) ── // The dock is an overlay: folding is visual-only and scroll geometry never // moves, so the thresholds can hug the bottom. Leave it → fold; reach it // again → re-open. The dead band keeps trackpad drift from chattering. const expandedRef = useRef(expanded); expandedRef.current = expanded; const pendingRef = useRef(!!pending); pendingRef.current = !!pending; const requiresRevealRef = useRef(requiresReveal); requiresRevealRef.current = requiresReveal; const revealedRef = useRef(revealed); revealedRef.current = revealed; // The plan marginalia's flank width (owner, plan-placement round): from // the DOCK container's right edge to the block's right edge — the bare // diagram's canvas. Tracked LIVE by a ResizeObserver on the block // (owner, resize round): every geometry change — window resize, sidebar // toggle, scrollbar appearance, the box's own rewrap — reports the real // width instantly, so the fade floor never lags behind reality (the old // render-time measurement went stale during drag-resizes and the mount // decision popped late). const [flankW, setFlankW] = useState(0); const flankWRef = useRef(0); // Part A of the same round (the resize mute): any block size change // stamps this — the fold machine ignores scroll events for a beat after, // because a resize/rewrap re-clamps scrollTop and emits PHANTOM scroll // events mid-transient (the box flickered to the bar and back). const geometryAtRef = useRef(0); useIsoLayoutEffect(() => { const block = blockRef.current; if (!block) return; const measure = () => { const next = Math.round(block.offsetWidth / 2 - DOCK_W / 2 - 2 * MARGIN_GAP); // Stamp the mute on WIDTH changes ONLY: column resizes are the // phantom-scroll source. Height changes are the dock's own doing // (morphs, question rewraps) — stamping those would self-mute the // fold machine and swallow the at-bottom auto re-expand right // after a fold (verified live: the box got stuck collapsed). if (Math.abs(next - flankWRef.current) > 1) { flankWRef.current = next; setFlankW(next); geometryAtRef.current = Date.now(); } }; measure(); const ro = new ResizeObserver(measure); ro.observe(block); return () => ro.disconnect(); }, []); // The overlay must not paint over the scroller's scrollbar (owner, scroll // round 2): the scroller reserves symmetric gutters (scrollbar-gutter: // stable both-edges), so inset the overlay's right edge by the gutter — // zero on macOS overlay scrollbars, the real width on classic platforms. const [sbInset, setSbInset] = useState(0); useEffect(() => { const el = scrollRef.current; if (!el) return; const measure = () => setSbInset(Math.round((el.offsetWidth - el.clientWidth) / 2)); measure(); window.addEventListener("resize", measure); return () => window.removeEventListener("resize", measure); }, [scrollRef]); // Wheel over the dock must still scroll the transcript (owner, scroll // round 2): the overlay is a SIBLING of the scroller, so the browser's // native scroll chaining stops at the overlay (the old in-flow dock // chained for free). Internal scrollers — the question area, textarea // overflow, the model menus — keep native scrolling, including their // own edge chaining; only when nothing under the cursor can take the // delta do we forward it to the transcript. useEffect(() => { const root = blockRef.current; const el = scrollRef.current; if (!root || !el) return; const canScroll = (n: HTMLElement, dir: number) => { if (n.scrollHeight - n.clientHeight < 2) return false; // not scrollable return dir < 0 ? n.scrollTop > 0 : n.scrollTop < n.scrollHeight - n.clientHeight - 0.5; }; const onWheel = (e: WheelEvent) => { if (e.deltaY === 0) return; for (let n = e.target as HTMLElement | null; n && n !== root; n = n.parentElement) { if (n instanceof HTMLElement && canScroll(n, e.deltaY)) return; // native } e.preventDefault(); el.scrollTop += e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1); }; root.addEventListener("wheel", onWheel, { passive: false }); return () => root.removeEventListener("wheel", onWheel); }, [scrollRef]); // ── The reserve: the spacer's height, reported upward ── // Grows immediately (typing, new variants). NEVER drops while a question // is open — a shrinking reserved tail is scroll geometry and the browser // clamp would pin a nearby learner to the bottom (the old oscillation). // In chat mode drops apply (the composer's typing shrink is today's // harmless in-flow behavior; nothing auto-expands there). const reserveRef = useRef(0); const blockRef = useRef(null); const offer = (blockH: number) => { const next = blockH + GRADIENT_H; if (next <= reserveRef.current + 0.5 && pendingRef.current) return; // fold immunity reserveRef.current = next; onReserve(next); }; // Explicit expand (bar click): the smooth scroll to the bottom takes // hundreds of ms, during which the position is still legitimately inside // the collapse zone — the machine would instantly re-collapse the dock // (open → snap shut → reopen at the bottom, the flicker). Suppress the // collapse branch until the reveal scroll settles or is interrupted. const expandIntentRef = useRef(false); const intentTimerRef = useRef>(undefined); const expandAndReveal = () => { expandIntentRef.current = true; setRevealed(true); // the teach-check invitation was accepted (W5) setExpanded(true); clearTimeout(intentTimerRef.current); intentTimerRef.current = setTimeout(() => { expandIntentRef.current = false; }, 2000); void Promise.resolve(scrollToBottom()).finally(() => { clearTimeout(intentTimerRef.current); expandIntentRef.current = false; }); }; useEffect(() => { const el = scrollRef.current; if (!el) return; const onScroll = () => { if (!pendingRef.current) return; // Resize mute (owner, resize round): a geometry change re-clamps // scrollTop and the browser emits the clamp as a scroll event — // reading the transient position folded the box to the bar for a // flicker. Real scrolling resumes after the brief mute window. if (Date.now() - geometryAtRef.current < 400) return; const d = el.scrollHeight - el.clientHeight - el.scrollTop; if (expandedRef.current) { if (d > COLLAPSE_DIST && !expandIntentRef.current) setExpanded(false); } else if (d < EXPAND_DIST) { // Teach checks stay collapsed until the learner opens them (W5): the // at-bottom auto-expand would pop the form over the teaching. if (requiresRevealRef.current && !revealedRef.current) return; setExpanded(true); void scrollToBottom(); } }; el.addEventListener("scroll", onScroll, { passive: true }); return () => el.removeEventListener("scroll", onScroll); }, [scrollRef, scrollToBottom]); // The settled block height (gradient excluded), offered to the spacer. // Only ever called with NATURAL heights — never the animated mid-morph // value (the iso effect's getAnimations guard wraps every call). const measureBlock = () => { const b = blockRef.current; if (b) offer(b.getBoundingClientRect().height); }; const mode = pending ? `question:${pending.toolCallId}` : "chat"; // The opening variant of a session appears instantly — the fade/rise is // reserved for mid-session transitions. const initialModeRef = useRef(mode); // Height morph: animate the shell from its previous height to its new one // whenever the variant (chat ↔ question ↔ collapsed bar) changes. Typing // growth keeps the same stateKey and is NOT animated. const shellRef = useRef(null); const stateKey = `${mode}:${pending ? expanded : "-"}`; const prevRef = useRef<{ key: string; height: number } | null>(null); useIsoLayoutEffect(() => { const el = shellRef.current; if (!el) return; const p = prevRef.current; if (p && p.key === stateKey) { // Same variant: keep the settled height fresh, but never measure while // a morph is in flight — getBoundingClientRect would read the ANIMATED // value and corrupt the start height of the next morph. if (el.getAnimations().length === 0) { const h = el.getBoundingClientRect().height; p.height = h; measureBlock(); // typing growth → the spacer grows with the box } return; } el.getAnimations().forEach((a) => a.cancel()); // flush to natural height before measuring const h = el.getBoundingClientRect().height; // Variant change → re-reserve. Growth applies instantly; a drop (the // fold, or question → composer) is filtered by the offer policy. measureBlock(); if (p && Math.abs(p.height - h) > 1) { el.animate([{ height: `${p.height}px` }, { height: `${h}px` }], { duration: 180, easing: "cubic-bezier(0.2, 0.8, 0.2, 1)", }); } prevRef.current = { key: stateKey, height: h }; }); return (
{setupCta}
{pending ? ( ) : ( composer )}
{/* The stats row: beneath the composer/question shell, not flush to the screen bottom — static height, outside the morphing shell so it never joins a height animation. */} {/* The scroll-down disc (moved here from the Conversation, owner refinement round 2026-09-24): anchored to the BLOCK's top edge — above the composer shell AND the stats row, in the gradient's transparent band — so it never overlaps any dock element. Because the anchor is the block itself (not the scroller's bottom), it rides every height change LIVE: typing growth, a pending question expanding the band, the 180ms fold morph. pointer-events-auto: the overlay is none by default. The isAtBottom/scrollToBottom context still reaches it — DockOverlay is a Conversation child. */}
{/* The plan marginalia (owner, plan-placement round): the bare diagram in the band's right flank — boundaless, meet-fitted, part of the lesson's page rather than a floating widget. Mounted only while a question owns the band (its reserved height is the canvas); folding the dock fades + blurs it out of existence, so reading the transcript in full wipes the margin clean. Click → popup. Width (owner, resize round): the box shrinks continuously with the flank; below MARGIN_MIN_W it FADES (never pops away — no mid-question unmount), and widening past the floor fades it back in. */} {pending && plan && ( )}
); }