"use client"; import { createContext, Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useChatSession } from "@/components/session"; 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, joinDictation, useDictation } from "@/components/dictation"; import { JobsContext } from "@/components/jobs-context"; import { StatsBar } from "@/components/stats-bar"; import { useActivity } from "@/components/activity-dock"; import { isProviderKeyRefusal, needsContinuation, type ChatInput, type ChatSettings, type LhMessage, type ModelSlotName } from "@/src/core"; import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block"; import { PlanMarginalia, planExists } 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 { TopBar } from "@/components/top-bar"; import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core"; import type { LhViewMessage } from "@/src/core"; import { PhaseChip, PhaseDivider } 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). */ function SystemLine({ children, testId }: { children: React.ReactNode; testId?: string }) { return (
{children}
); } function isSubagentSteer(m: LhViewMessage): 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: LhMessage[]; 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 hook holds the // settled terminal's callback — a stable ref assigned below, once // syncFromServer exists (refs, not closures: no stale capture). const reattachSettledRef = useRef<() => void>(() => {}); // THE SESSION HOOK (Wave D, owner-approved 2026-09-26): the app-owned // replacement for useChat + the AI-SDK transport. The record is native // history; the VIEW (grouped, golden-master) is derived client-side // (core/view.ts projectView); sends are ONE explicit input event each // ({chatId, input} — the stash indirection died with the SDK), and the // turn stream is the VERBATIM AgentEvent passthrough the server emits. const { messages, nativeMessages, send, submitAnswer, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } = useChatSession({ chatId, initialMessages, initiallyInFlight }); // The reattach settled terminal → the sync-and-continue seam (below). setReattachSettled(() => reattachSettledRef.current()); // 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; void send({ type: "text", text: initialPrompt }); } }, [initialPrompt, initialMessages.length, send]); // 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" && needsContinuation(initialMessages)) { void send({ type: "continue" }); } // 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 composer's value MIRROR ("nothing gets lost" round, owner * 2026-10-01): a dictation flush lands via setState, which a closure * waiting through `await finish()` cannot observe in time. Kept * current at every write site via applyText. */ const textRef = useRef(""); const applyText = useCallback((v: string) => { textRef.current = v; setText(v); }, []); // 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): 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?: LhMessage[] } | null; if (Array.isArray(record?.messages)) setMessages(record.messages); } if (cancelled) return; if (draftRef.current !== null) { applyText(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({ // Ref-first (see textRef): the join is computed from the mirror so a // send resuming after `await finish()` reads the dictated tail. append: (t) => applyText(joinDictation(textRef.current, t)), }); /* When a dictation run ends, the bare composer takes focus (owner * 2026-10-01) — the hands are already on the keyboard from the stop. * Falling edge of `active` only (idle→idle never focuses), and ONLY the * composer: with a question pending the Composer is unmounted, the ref * is null, and the thinking field is left alone by design. */ const dictationWasActiveRef = useRef(false); const composerInputRef = useRef(null); useEffect(() => { if (dictation.active) { dictationWasActiveRef.current = true; } else if (dictationWasActiveRef.current) { dictationWasActiveRef.current = false; composerInputRef.current?.focus(); } }, [dictation.active]); // 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. // A LIVE dictation also makes the composer sendable (owner 2026-10-01): // the spoken text is deferred in the run's buffer — Enter must be able // to trigger the finish-first send (see onSend), not be gated out by an // empty textarea. If nothing was said, the send is a harmless no-op. const canSend = (text.trim().length > 0 || dictation.active) && !pending && !turnInFlight; const syncFromServer = useCallback(async () => { const res = await fetch(`/api/chats/${chatId}`); if (!res.ok) return; const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[] }; if (Array.isArray(record.turns)) setTurns(record.turns); if (!Array.isArray(record.messages)) return; setMessages(record.messages); // The continuation seam (contracts.ts protocol §4): a job result // landing in the record leaves the agent owing a continuation — the // record ENDS on un-consumed results, the dock owns no question, and // nothing runs. The native fold (needsContinuation) is the old SDK // lastAssistantMessageIsCompleteWithToolCalls check. if (status === "ready" && needsContinuation(record.messages)) { void send({ type: "continue" }); } }, [chatId, setMessages, send, 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 // (the session hook's adoptRecord): 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 adoptAppendOnly = useCallback(async () => { try { const res = await fetch(`/api/chats/${chatId}`); if (!res.ok) return; const record = (await res.json()) as { messages?: LhMessage[] }; if (!Array.isArray(record.messages)) return; adoptRecord(record.messages); } catch { /* transient — the idle full sync is the net */ } }, [chatId, adoptRecord]); // 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); // Real plan presence — gates the marginalia's clickable flank. A JSX // element is always truthy; gating on the node alone mounted a phantom // (invisible, clickable) button in the bottom-right whenever a question // was pending with no plan yet (owner report, UI round 2026-10-01). const hasPlan = useMemo(() => planExists(messages), [messages]); // 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]); // ── 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?: LhMessage[]; }; // 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)) adoptRecord(record.messages); 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 = () => { if (busy || pending) return; /* FINISH DICTATION FIRST (owner, 2026-10-01): a learner who keeps * talking right through Enter must not lose the tail — stop the run, * let the last finals land in the composer, then send the REF value * (the flush lands via setState, invisible to this closure). */ if (!dictation.active) { doSend(); return; } void dictation.finish().then(doSend); }; const doSend = () => { if (busy || pending) return; const t = textRef.current.trim(); if (!t) return; draftRef.current = t; // the refusal flow's preserve-and-replay seam applyText(""); void send({ type: "text", text: t }); }; return ( { void send({ type: "text", 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. */}