"use client"; import { createContext, Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useChatSession } from "@/components/session"; import { visibleQueuedSteers } from "@/components/session-core"; import { SetupDialog } from "@/components/setup-dialog"; import { Hint } from "@/components/ui/hint"; import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta"; import { useStickToBottomContext, type StickToBottomContext } 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 { PathText } from "@/components/file-path-chip"; import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning"; import { Shimmer } from "@/components/ai-elements/shimmer"; import { BrailleSpinner } from "@/components/braille-spinner"; import { LiveToolCard } from "@/components/artifacts/live-tool-card"; 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 { useEnterSubmits } from "@/components/enter-submits"; import { blurAfterPointerClick } from "@/components/focus-hygiene"; import { dockStateKey } from "@/components/dock-state-key"; import { clearOpeningPrompt, readOpeningPromptEntry, stashOpeningPrompt, OPENING_PROMPT_FAILURE } from "@/components/opening-prompt"; import { forwardDockWheel, useDockHeight } from "@/components/dock-height"; import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers"; import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation"; import { useFieldCycle } from "@/components/field-cycle"; import { JobsContext } from "@/components/jobs-context"; import { StatsBar } from "@/components/stats-bar"; import { useServerEvent, useServerResync } from "@/components/server-events"; import { isProviderKeyRefusal, firstSourceTextParts, messageAnchorId, textPartMessageId, kbdEventForAction as kbdEventForChat, 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 { AbortTurnDialog, type AbortMode } from "@/components/abort-turn-dialog"; import { ArrowRightIcon, DumbbellIcon, WaypointsIcon } from "lucide-react"; import type { PlanBlockData } from "@/src/core"; import { QuestionDock } from "@/components/question-dock"; import { SessionFind } from "@/components/session-find"; import { SessionMessageNavigation } from "@/components/session-navigation"; import { PracticeDock } from "@/components/practice-dock"; import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards"; import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card"; import { TopBar } from "@/components/top-bar"; import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, QuestionFormDraft, TurnUsage, SessionPricing } from "@/src/core"; import type { LhViewMessage } from "@/src/core"; import { PhaseChip, PhaseDivider } from "@/components/phase-divider"; import { CompactionCutDivider, CompactionMomentDivider, type CompactionDividerData, } from "@/components/compaction-divider"; import { CoverageRing } from "@/components/extraction-coverage"; import { Button } from "@/components/ui/button"; import { activeProblemOf, dismissedAnswer, endsInFailedTurn, findPendingToolCall, phaseAt3, phaseTimeline, teachStepIndex, toolQuestionKind, questionNeedsReveal, } from "@/src/core"; import { ReadingThinkingField } from "@/components/reading-thinking"; import { currentUserThinkingOpen, setAllUserThinkingOpen } from "@/components/thinking-block"; /** 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; } } /** Retry sends no composer text. Its refusal must not resurrect the draft * from an earlier, already-durable text send over today's unsent composer. * Retire that fallback synchronously, never when the async Retry settles: * a later ordinary send may own a new recovery draft by then. */ export function retryFailedResponse( send: (input: ChatInput) => void, submittedDraft: { current: string | null }, ): void { submittedDraft.current = null; void send({ type: "retry" }); } /** 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, initialError, initialPrompt, initiallyInFlight, initiallyCompacting = false, initialConfigured = false, kind = "teach", }: { chatId: string; title: string; initialMessages: LhMessage[]; /** Memory-only settlement notice supplied on reopening. */ initialError?: string; /** The session kind (practice sessions, owner 2026-10-04): practice * renders no phase chip (no probe→teach arc applies) and a Practice * tag rides the header instead. */ kind?: "teach" | "practice"; initialPrompt?: string; /** A teacher turn is running server-side but attached to no client * (navigated away mid-turn, returned while in flight). */ initiallyInFlight?: boolean; /** A compaction holds the chat's model gate at mount (navigated away * mid-compaction — the registry is live server state, the * compaction-changed event already fired). The transcript spinner * and send gates key on it. */ initiallyCompacting?: 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, hasConfirmedConversation, contextTokens, send, continueIfOwed, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, questionDraft, saveQuestionDraft, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } = useChatSession({ chatId, initialMessages, initialError, initiallyInFlight }); const sessionFindScopeRef = useRef(null); const scrollContextRef = useRef(null); const sourceTextParts = useMemo(() => firstSourceTextParts(messages), [messages]); // The reattach settled terminal → the sync-and-continue seam (below). setReattachSettled(() => reattachSettledRef.current()); // 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]); // ESC-abort guard (owner, 2026-10-02): the abort chain opens a confirm // popup EVERYWHERE (streams and pending questions alike). Both window // listeners fire per Esc, so the state gate here — not stopPropagation // ordering — is what keeps the popup's own Esc from chaining. // LAYERED ESC (owner, 2026-10-04): an open overlay CLAIMS Esc — closing // itself — before the abort chain may open its confirm. A mermaid focus, // the plan popup, or the setup dialog over a pending question must not // dismiss the question underneath. const [abortAsk, setAbortAsk] = useState(null); const abortAskRef = useRef(null); abortAskRef.current = abortAsk; useEffect(() => { const onAbort = () => { const target = escAbortTarget({ abortAsk: abortAskRef.current !== null, mermaidFocus: mermaidFocusRef.current !== null || figureFocusRef.current !== null, planOpen: planOpenRef.current, setupOpen: setupOpenRef.current, pending: pendingRef.current !== null, problem: activeProblemRef.current !== null, busy: busyRef.current, }); if (target === "question") setAbortAsk("question"); else if (target === "problem") setAbortAsk("problem"); else if (target === "turn") setAbortAsk("turn"); else if (target === "mermaid") setMermaidFocus(null); else if (target === "plan") setPlanOpen(false); // setup closes itself; null = nothing to do — both claimed. }; window.addEventListener("lh:kbd-abort-turn", onAbort); return () => window.removeEventListener("lh:kbd-abort-turn", onAbort); }, []); const confirmAbort = useCallback( (mode: AbortMode) => { setAbortAsk(null); if (mode === "turn") { abortInFlightTurn(); return; } // From a pending question the dismiss is silent (A2, 2026-10-03): // the skipped toolResult lands via the SILENT route, no turn, no // wake — the next plain send is the wake. MID-TURN (owner, // 2026-10-04 — the GLM loop): the question can only dangle because a // turn is STILL RUNNING, so the discard ALSO kills that turn; the // route parks the dismissal (202) and the settle applies it. const p = pendingRef.current; if (p) { const question = (p.input as { question?: string } | null)?.question ?? ""; const reading = readingThinkingRef.current.trim() || undefined; clearReadingThinking(); const answer = dismissedAnswer(toolQuestionKind(p.tool), question); void dismissQuestion( p.toolCallId, reading ? { ...answer, readingThinking: reading } : answer, ); if (busyRef.current) abortInFlightTurn(); return; } // The practice discard (owner, 2026-10-04): ask-style — the // silent marker route; the conclude synthesis rides the user's // next input. The composer returns immediately. Mid-turn the // discard also kills the running turn (same park + settle law). const problem = activeProblemRef.current; if (problem) { void dismissProblem(problem.toolCallId); if (busyRef.current) abortInFlightTurn(); } }, [abortInFlightTurn, dismissQuestion, dismissProblem], ); // 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) continueIfOwed(initialMessages); // mount-only by design (turnInFlight read at initial value) }, []); const pending = findPendingToolCall(messages); // The abort handler reads the pending question from a ref (the window // listener is mount-scoped; the value it needs is render-scoped). const pendingRef = useRef(pending); pendingRef.current = pending; // THE ACTIVE PRACTICE PROBLEM (practice sessions, 2026-10-04): the // same dock-ownership rule as a pending question — an active problem // replaces the composer. Computed from the NATIVE record (the fold is // the gate the tools themselves read), not the projected view. const activeProblem = activeProblemOf(nativeMessages); const activeProblemRef = useRef(activeProblem); activeProblemRef.current = activeProblem; 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]); // The practice dock's send (ONE lane, owner 2026-10-05): POSTs the // thinking against the ACTIVE problem — whether it claims an answer is // read from the text by the agent, not declared here. Starts a turn; // the dock stays mounted (the problem is still active until the agent // concludes). const submitThinking = useCallback( (text: string) => { const problem = activeProblemRef.current; if (!problem) return; void send({ type: "thinking", problemCallId: problem.toolCallId, text }); }, [send], ); // Teach-phase check (W5, vision §2): a pending QUIZ 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. Both teach quizzing tools obey the // law (owner, 2026-10-09: explain checks join quiz checks behind the // reveal); predictions of either mechanic and ask always arrive open. // 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 && questionNeedsReveal(pending.tool, pending.input, phaseAt3(messages)); /* The READING-THINKING channel (owner 2026-10-03): a draft field for * thinking while reading the teaching prose, mounted while a check waits * behind its reveal, riding the check answer (or its dismissal) as the * separate `readingThinking` field. Reset per pending question. */ const [readingThinking, setReadingThinking] = useState(""); const readingThinkingRef = useRef(""); // ⌘T (toggle-user-thinking, owner round 2026-10-07): set EVERY user // thinking row to one state — transcript rows default open, so the // first press collapses everything, the second re-expands. useEffect(() => { const onToggle = () => setAllUserThinkingOpen(!currentUserThinkingOpen()); window.addEventListener(kbdEventForChat("toggle-user-thinking"), onToggle); return () => window.removeEventListener(kbdEventForChat("toggle-user-thinking"), onToggle); }, []); const applyReadingThinking = useCallback((v: string) => { readingThinkingRef.current = v; setReadingThinking(v); }, []); const clearReadingThinking = useCallback(() => { readingThinkingRef.current = ""; setReadingThinking(""); }, []); const pendingQuestionId = pending?.toolCallId ?? null; useEffect(() => { // Different calls never share a draft; a failed submission of THIS // call restores the reading channel alongside the form's snapshot. const reading = pendingQuestionId ? questionDraft(pendingQuestionId)?.readingThinking ?? "" : ""; readingThinkingRef.current = reading; setReadingThinking(reading); }, [pendingQuestionId, questionDraft]); // 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]); 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); }, []); // Saved slot completeness is live, but the session's selected route // comes from its own ordinary model control, never today's Strong pin. const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured); const catalogRef = useRef(catalog); catalogRef.current = catalog; const [selectedModel, setSelectedModel] = useState(); const [setupOpen, setSetupOpen] = useState(false); /** The last explicitly-sent draft (composer send): the * refusal flow restores it into the composer, where it waits for the * learner to re-send (Save never auto-sends, owner 2026-10-09). * Auto-continuations (probe answers) never set it — their refusal * opens recovery with no replay. */ const draftRef = useRef(null); const openingDraftRef = useRef(null); const [openingError, setOpeningError] = useState(false); const openingMountedRef = useRef(false); useEffect(() => { openingMountedRef.current = true; return () => { openingMountedRef.current = false; }; }, []); const deliverOpeningPrompt = useCallback((prompt: string) => { openingDraftRef.current = prompt; // Persist uncertainty BEFORE the POST: another mount must restore, // not race the still-arriving request. No storage means no auto-send. if (!stashOpeningPrompt(chatId, prompt, true)) { applyText(prompt); setOpeningError(true); return; } setOpeningError(false); draftRef.current = prompt; // The existing 503 flow must restore it too. applyText(""); void send({ type: "text", text: prompt }, () => { clearOpeningPrompt(chatId); openingDraftRef.current = null; if (draftRef.current === prompt) draftRef.current = null; }).then(accepted => { if (!accepted && openingMountedRef.current && openingDraftRef.current === prompt && !textRef.current) { applyText(prompt); } }); }, [chatId, send, applyText]); const sentInitial = useRef(false); useEffect(() => { if (sentInitial.current) return; sentInitial.current = true; const entry = readOpeningPromptEntry(chatId); const prompt = initialPrompt || entry?.text; // Legacy links still work. if (!initialPromptOwed(initialMessages, prompt)) { if (initialMessages.some(m => m.role === "user" || m.role === "assistant")) clearOpeningPrompt(chatId); return; } openingDraftRef.current = prompt!; if (entry?.attempted || initiallyInFlight) { applyText(prompt!); // Interrupted start: the learner explicitly resends. return; } deliverOpeningPrompt(prompt!); }, [chatId, initialPrompt, initialMessages, initiallyInFlight, deliverOpeningPrompt, applyText]); useEffect(() => { if (!hasConfirmedConversation) return; clearOpeningPrompt(chatId); // A later canonical read may confirm a lost acknowledgment. Clear // only the unchanged recovered draft, never the learner's newer work. const opening = openingDraftRef.current; if (opening !== null && textRef.current === opening) applyText(""); if (draftRef.current === opening) draftRef.current = null; openingDraftRef.current = null; setOpeningError(false); }, [chatId, hasConfirmedConversation, applyText]); // 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). The draft stays in the composer until the learner // re-sends it (owner ruling 2026-10-09). useEffect(() => { if (!error) return; const missing = parseModelsRefusal(error.message); // The key-refusal twin (zero-keys wave): a pinned model whose // provider key is gone also restores the draft. With another provider // connected, repair belongs to the ordinary current-session picker; // otherwise setup derives its exclusive provider/loading stage. 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(); if (keyRefused && catalogRef.current?.providers.some((p) => p.keyPresent)) openModelPicker(); else 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. // TAB CYCLES THE CHAT'S FIELDS (owner, keyboard round 2026-10-06): // reading-thinking → the active dock's field → the composer, in // visual order, forward-only with wrap; Tab from anywhere else // focuses the first live field (navigate IN). Gathered from live // DOM at event time — the docks mount and unmount, refs would go // stale. The quiz dock's rows↔thinking claim never reaches here. const cycleChatFields = useCallback((): readonly HTMLElement[] => { const pick = (sel: string) => Array.from(document.querySelectorAll(sel)); return [ ...pick('[data-testid="reading-thinking-field"] [data-file-path-input]'), ...pick('[data-testid="question-dock"] [data-file-path-input]'), ...pick('[data-testid="practice-dock"] [data-file-path-input]'), ...pick('[data-testid="composer"] [data-file-path-input]'), ]; }, []); useFieldCycle(cycleChatFields); 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. // Steering (2026-10-03): a turn in flight no longer gates the composer — // Enter while a turn runs STEERS it (the quiet queue above the composer); // a pending question still owns the interaction (the dock replaces the // composer, and there is no live turn to steer anyway — questions end // turns). // Compaction (owner round 14): the RUNNING state — compaction-changed // flips it; the send paths, canSend, and the docks gate on it (the // server's registry mutex is the structural gate; this is the // affordance). const [compacting, setCompacting] = useState(initiallyCompacting); // A failed model response (issue #110): Retry keeps the saved input // and completed work, without another learner message. Offered from // "error" too: a dropped stream leaves the model there, and send() // recovers from it. Later compaction requires new input instead. const retryable = (status === "ready" || status === "error") && !busy && !compacting && endsInFailedTurn(nativeMessages); const retryFailedTurn = () => retryFailedResponse(send, draftRef); const canSend = (text.trim().length > 0 || dictation.active) && !pending && !activeProblem && !compacting; /* THE PRACTICE PAUSE (owner round 16): between problems — practice * kind, nothing active, nothing in flight, and at least one problem * has ever been posed (the fresh session's opening turn is owed, not * the move-on). Enter-on-empty and the Next chip both key on it. */ const nextReady = kind === "practice" && !pending && !activeProblem && !compacting && !busy && nativeMessages.some( (m) => (m as { role?: string }).role === "assistant" && Array.isArray((m as { content?: unknown[] }).content) && (m as { content: Array<{ type?: string; name?: string }> }).content.some( (b) => b.type === "toolCall" && b.name === "problem", ), ); const sendNext = useCallback(() => { if (!nextReady) return; void send({ type: "next" }); }, [nextReady, send]); /* ENTER FROM ANYWHERE (owner round 16 refinement, 2026-10-06 — * keyboard-first): the move-on must not require composer focus. The * window-level Enter (typing surfaces and buttons excluded — a * focused composer/button keeps its own lane, so no double fire) * fires the signal from body focus, a scroll, anywhere. sendNext * self-guards on nextReady, so the listener is inert outside the * pause — the dock-mounted listeners are gone by then (docks unmount * when nothing is active; the two never overlap). */ useEnterSubmits(sendNext); 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[]; pricing?: SessionPricing; turnError?: string }; if (Array.isArray(record.turns)) setTurns(record.turns); setPricing(record.pricing); if (!Array.isArray(record.messages)) return; setMessages(record.messages, record.turnError); // 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. // Current model truth also guards retained/live failures: canonical // sync must never automatically replace a reply that failed to save. continueIfOwed(record.messages); }, [chatId, setMessages, continueIfOwed]); // 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 dock // nudges that lived here died at S5: dispatch writes announce jobs-changed // from the store, extraction announces activity-changed at setPhase.) 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. adoptionWindowOpenRef.current = true; const timers = [400, 1000, 2000].map((ms) => setTimeout(() => { if (adoptionWindowOpenRef.current) { void adoptAppendOnly(); } }, 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]); 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); // Figure focus (2026-10-07): the figure card's fullscreen button — same // overlay shell as mermaid, but carrying the raw svg + the card's invert // state so the fullscreen view stays faithful to the toggled card. const [figureFocus, setFigureFocus] = useState<{ svg: string; inverted: boolean } | null>(null); // Overlay mirrors for the layered-Esc gate (owner 2026-10-04): the // lh:kbd-abort-turn handler reads these at EVENT time — declared here // beside their states, consumed below wherever the states live. const mermaidFocusRef = useRef(mermaidFocus); mermaidFocusRef.current = mermaidFocus; // The figure focus rides the mermaid layer in the Esc gate — the same // overlay class (session-scoped diagram focus, Esc claimed at the // document level by the overlay itself); the window chain must stay out. const figureFocusRef = useRef(figureFocus); figureFocusRef.current = figureFocus; const planOpenRef = useRef(planOpen); planOpenRef.current = planOpen; const setupOpenRef = useRef(setupOpen); setupOpenRef.current = setupOpen; // 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 and read-time server Pi // pricing projection. 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([]); const [pricing, setPricing] = 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[]; pricing?: SessionPricing }; if (Array.isArray(record.turns)) setTurns(record.turns); setPricing(record.pricing); } 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 settle (the SLIMMED watch — push-architecture S3, // 2026-10-02): our own stream just ended. Two instant LOCAL actions // only — the turns-only refetch lands the log entry while the server's // persist/endTurn lags by milliseconds, and the coverage ring re-reads // (this turn's messages just joined the uncovered mass). The ADOPTION // LOOP and the HANDOFF WATCH that lived here are RETIRED: a // server-initiated turn (the probe→teach handoff, a job wake, another // tab) is now ANNOUNCED on the events channel — the turn-started // subscription (below the jobs poll) attaches to it live. No polling // window can expire; the freeze class is gone. const prevStatusRef = useRef(status); useEffect(() => { const transitioned = prevStatusRef.current !== "ready" && status === "ready"; // Every path (the old early-return skipped this on the transition // branch — the re-fire-on-dep-churn wart, fixed S3). prevStatusRef.current = status; if (!transitioned) return; // 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); the ring's own running-poll handles runs. setCoverageTick((t) => t + 1); // CANONIZATION (fix B, the duplicate-previous-message bug, 2026-10-02): // turn-settled for OUR OWN turn arrives while the stream is still open // (endTurn emits from onFinish before the response terminates, so the // SSE frame reliably beats [DONE]) — the handler below cannot sync // there, it flags instead, and THIS flip consumes the flag: the record // reflects durable truth (a failed settlement retains its notice, // not its unsaved reply), so replacing the view absorbs the tail's // streamed c_-id copies into base under their server ids. Without // this the tail kept those copies forever and the next send's // steer-adoption window rendered them a second time. The wake handoff // cannot be missed by this sync: the wake fires only after the idle // gates + grace (≥750ms past this flip), so its turn-started event // lands at an idle, already-canonical view and attaches normally. if (settleAnnouncedRef.current) { settleAnnouncedRef.current = false; void syncFromServer(); } // (The extraction-discovery nudges that lived here died at S5: the // pipeline announces every phase on the events channel.) }, [status, syncTurns, syncFromServer]); // 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 fetch 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. PUSH (architecture S4, 2026-10-02): the store // announces every job write on the events channel; the interval poll and // its pollMs/jobPartCount gating are RETIRED. The view fetches once on // mount, once per jobs-changed announcement (trailing-debounced — a // sub-agent run writes activity-line bursts; 250ms coalesces them at or // below the old 1s poll cadence), and once on channel resync. // // 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 fetch adopts the finished record. const [jobs, setJobs] = useState([]); 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); // (The dispatch nudge effect that lived here — refreshActivity per new // dispatch tool part — died at S5: the store announces jobs-changed at // the dispatch write; the dock subscribes.) // First-fetch guard (the old first-tick guard): the initial 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. const firstFetchRef = useRef(true); // The trailing debounce for jobs-changed announcements (one timer), and // the ref the timer fires through (a stale closure must never fetch an // old chatId). const fetchDebounceRef = useRef | undefined>(undefined); const fetchJobsRef = useRef<() => void>(() => {}); const fetchJobs = useCallback(async () => { try { const res = await fetch(`/api/jobs?chatId=${encodeURIComponent(chatId)}`); if (!res.ok) return; const data = (await res.json()) as { jobs?: JobRecord[]; turnInFlight?: boolean }; const list = data.jobs ?? []; for (const j of list) { const prev = jobStatusRef.current.get(j.id); if (prev === "running" && j.status !== "running") { // A settle — the blob leaves the dock via the store's jobs-changed // announce at this very write (S5); this handler owns the RECORD. // 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(); } } jobStatusRef.current.set(j.id, j.status); } firstFetchRef.current = false; if ( pendingSyncRef.current && !localStreamRef.current && data.turnInFlight !== true ) { pendingSyncRef.current = false; void syncFromServer(); } setJobs(list); // (Wake-turn adoption that lived here — the turnInFlight attach // line — died at S3: turn-started on the events channel owns // discovery now. turnInFlight is STILL READ above: the settle // logic's idle check needs it.) } catch { /* transient fetch failure — the next jobs-changed announcement re-fetches */ } }, [chatId, syncFromServer, adoptAppendOnly]); fetchJobsRef.current = () => void fetchJobs(); // Mount + chat switch: the IMMEDIATE (non-debounced) initial read. The // firstFetch guard resets with the old rule — true only when this // session has never seen a job (a chat switch keeps the map: job ids // are unique per chat, and a newly-seen running job may still nudge). useEffect(() => { firstFetchRef.current = jobStatusRef.current.size === 0; void fetchJobs(); }, [fetchJobs]); // identity changes exactly on chatId useServerEvent("jobs-changed", (p) => { if (p.chatId != null && p.chatId !== chatId) return; // ours or unattributed if (fetchDebounceRef.current) clearTimeout(fetchDebounceRef.current); fetchDebounceRef.current = setTimeout(() => { fetchDebounceRef.current = undefined; fetchJobsRef.current(); }, 250); }); // Reconnect catch-up: events missed while the channel was down. useServerResync(() => fetchJobsRef.current()); useEffect( () => () => { if (fetchDebounceRef.current) clearTimeout(fetchDebounceRef.current); }, [], ); // ── Server-initiated turn discovery (push-architecture S3, 2026-10-02) ── // Every turn THIS chat runs is announced on the events channel — the // probe→teach handoff, a job wake, another tab's send, the // missed-on-mount case. This replaces the retired watch loop and the // 1500ms fallback record poll: discovery is no longer a polling window // that can expire. Handlers read REFS, not closure state — events fire // between renders, and a ref holds the live value at event time. const statusRef = useRef(status); statusRef.current = status; /** Fix B (2026-10-02): turn-settled arrived for OUR OWN turn — the * record is durable (endTurn fires only after persistTurn); the ready * flip consumes this flag to canonize the view onto the record. See the * settle effect and the turn-settled handler below. */ const settleAnnouncedRef = useRef(false); useServerEvent("turn-started", (p) => { // Known non-issue: beginTurn emits before registerTurn in the same // synchronous request flow; a GET /stream inside that sub-ms window // gets a settled response — unwinnable by a network round-trip, // self-healing via the eventual turn-settled. Do not engineer around. if (p.chatId !== chatId) return; if (localStreamRef.current) return; // our OWN turn — the 409-race guard if (turnInFlightRef.current) return; // already attached attachToRunningTurn(); }); useServerEvent("turn-settled", (p) => { if (p.chatId !== chatId) return; if (localStreamRef.current) { // Our own [DONE] path owns the BUSY settle — but not a no-op (fix B, // 2026-10-02): the event is the record-durable guarantee (endTurn // fires only after persistTurn), and it reliably beats [DONE] here // (endTurn emits from onFinish before the response terminates). Flag // it; the ready flip consumes the flag and canonizes the view onto // the record. (If [DONE] somehow won this race instead, this branch // didn't run and the fully-idle branch below already synced.) settleAnnouncedRef.current = true; return; } if (turnInFlightRef.current) { // The attached stream's terminal frame owns the normal settle — // syncing HERE would swap a stale snapshot mid-stream (the // continuation-coalescing law, see the jobs poll above). Only // the error case heals: the attached stream died, and the settle // says persistTurn has made the record durable truth. if (statusRef.current === "error") { setTurnInFlight(false); clearError(); void syncFromServer(); } return; } // Fully idle — a turn we never saw (every window missed): adopt now. void syncFromServer(); }); useServerEvent("compaction-changed", (p) => { // The settle-kick's background summary: while it runs, sends gate // (the next turn's server-side ensureCompacted awaits it anyway — // this only keeps the affordance honest). On finish, record-changed // (emitted by the compaction write itself) carries the marker in. if (p.chatId !== chatId) return; setCompacting(p.running); }); useServerEvent("record-changed", (p) => { // The silent dismissal write (A2, 2026-10-03): no turn ever ran, so // only the record moved. Other tabs and post-error windows adopt; // our OWN dismissal already rendered its optimistic append — syncing // here too is harmless (the adoption law swaps local ids for the // server's). Busy states ignore it: a running turn owns the view, // and the write is 409-guarded against exactly that anyway. if (p.chatId !== chatId) return; if (localStreamRef.current || turnInFlightRef.current) return; void syncFromServer(); }); const onSend = () => { if (pending || compacting) 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) { doSteerOrSend(); return; } void dictation.finish().then(doSteerOrSend); }; const doSend = () => { if (busy || pending || compacting) return; const t = textRef.current.trim(); if (!t) return; if (zeroKeys) { setSetupOpen(true); return; } if (selectedModelUnavailable(catalog, selectedModel)) { openModelPicker(); return; // No optimistic send and no change to the learner's draft. } if (openingDraftRef.current !== null) { deliverOpeningPrompt(t); return; } draftRef.current = t; // the refusal flow's preserve-and-replay seam applyText(""); void send({ type: "text", text: t }); }; /* STEER VS SEND (owner arc 2026-10-03): a send while a turn runs steers * it — the quiet queue + the live agent's steering rail; the turn's own * stream lands the card when pi drains it. The branch is BUSY, not * turnInFlight: the own-stream case CLEARS turnInFlight (the local * dedup, chat.tsx ~370), so branching there swallowed Enter entirely * during our own turn (owner live report, fixed same day). busy covers * own stream AND foreign/wake turns — both have a live agent by the * time the send's POST resolved, so the steer lands. Idle takes the * plain send; a raced settle 409s and falls back to exactly that. */ const doSteerOrSend = () => { if (pending || compacting) return; const t = textRef.current.trim(); if (!t) return; if (busy) { applyText(""); void steer(t); return; } doSend(); }; return ( { void send({ type: "text", text: t }); }, openSetup: () => setSetupOpen(true), }} > setFigureFocus({ svg, inverted }), }} > {/* 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. */}