"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { CornerDownLeftIcon, DumbbellIcon } from "lucide-react"; import { GraphView } from "@/components/graph-view"; import { MinimalHome } from "@/components/minimal-home"; import { SetupDialog } from "@/components/setup-dialog"; import { ComposerActions, NodePickerControl } from "@/components/composer-actions"; import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta"; import { DictationBar, joinDictation, useDictation } from "@/components/dictation"; import { useFieldCycle } from "@/components/field-cycle"; import { SidebarToggle } from "@/components/sidebar"; import { StatsBar } from "@/components/stats-bar"; import { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt"; import { SessionStarter } from "@/components/start-session"; import type { TurnUsage } from "@/src/core"; /** * Home — the knowledge graph is the main view, full-bleed edge to edge * (owner, graph-view round 1): the canvas owns the whole column, running * under the header and the new-session dock, both of which float over it * with no gradient scrims. Sessions live in the toggleable sidebar. */ export function Home({ initialConfigured, rendering = true, }: { initialConfigured: boolean; /** GRAPH RENDERING off (owner, 2026-10-06): the home's main view is * the chains view, not the canvas. Read server-side per navigation — * changing the setting applies on the next visit home. */ rendering?: boolean; }) { const router = useRouter(); const [topic, setTopic] = useState(""); /* The topic MIRROR ("nothing gets lost" round, 2026-10-01 — same * mechanics as the chat composer, owner's consistency ruling): a * dictation flush lands via setState, invisible to a closure that * waited through `await finish()`. Kept current at every write. */ const topicRef = useRef(""); // First-run gate (model-slots wave, owner 2026-09-20): chats cannot // start until the user has picked all three slots — no defaults for // 33 providers. The dock shows the CTA while unconfigured; submitting // a topic opens the setup dialog WITH THE DRAFT PRESERVED (Save // stores slots only; the learner sends the message themselves). const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured); const [setupOpen, setSetupOpen] = useState(false); // Voice dictation (STT wave, 2026-09-17): finals append at the end of the // topic draft; the strip lives in the stats bar's middle slot. const dictation = useDictation({ // Ref-first: the join reads the mirror so the send resuming after // `await finish()` sees the dictated tail. append: (t) => { const v = joinDictation(topicRef.current, t); topicRef.current = v; setTopic(v); }, }); const [busy, setBusy] = useState(false); const startingRef = useRef(false); const starterRef = useRef(new SessionStarter()); const [startError, setStartError] = useState(false); /* Run end focuses the topic input (owner, 2026-10-01): the hands are on * the keyboard from the stop. Falling edge only; this surface's only * dictation target IS this textarea, so no case-split is needed. */ const topicInputRef = useRef(null); // Tab navigates INTO the topic composer (owner, keyboard round // 2026-10-06): the only text field on this surface — and home's // composer is its OWN textarea, not the shared Composer component, // so the chain is the ref itself (the smoke caught a selector that // matched nothing here). const cycleTopicField = useCallback( (): readonly HTMLElement[] => (topicInputRef.current ? [topicInputRef.current] : []), [], ); useFieldCycle(cycleTopicField); const dictationWasActiveRef = useRef(false); useEffect(() => { if (dictation.active) { dictationWasActiveRef.current = true; } else if (dictationWasActiveRef.current) { dictationWasActiveRef.current = false; topicInputRef.current?.focus(); } }, [dictation.active]); /* PRACTICE MODE (owner, 2026-10-04): the graph composer's teach | * practice switch — practice creates a kind:"practice" session and * may carry proposition picks from the chain table (the chip above * the composer opens the picker; the picks seed the opening prompt). * Not enforcing any input format — the picks are a convenience. */ const [mode, setMode] = useState<"teach" | "practice">("teach"); const [practiceProps, setPracticeProps] = useState<{ id: string; name: string; statement: string }[]>([]); const [chainSelectOpen, setChainSelectOpen] = useState(false); // The chain table is CONTROLLED off this id set (the lifted pick). const pickedIds = useMemo(() => new Set(practiceProps.map((p) => p.id)), [practiceProps]); /* FINISH DICTATION FIRST (owner, 2026-10-01): Enter may arrive while a * run is live with the topic still deferred in the run's buffer — stop * the run, let the last finals land, THEN read the REF mirror (not the * state closure) and run. Applies before the setup/key guards too, so * an intercepted draft is the complete one. */ const start = () => { if (!dictation.active) { void begin(); return; } void dictation.finish().then(() => begin()); }; const begin = async () => { const t = topicRef.current.trim(); if (!t || startingRef.current) return; // Unconfigured: the send INTERCEPTS into the setup flow (the draft // stays in the textarea; the learner sends it after Save). if (!configured) { setSetupOpen(true); return; } // The pre-send KEY guards (owner interlude, no-orphan-chats): the // models case guards BEFORE creation; the key cases must too — a // refused first send would otherwise leave an orphan error-shell // chat in the sidebar (the 503 lands only after POST /api/chats // already created the record). Zero providers opens connection setup; // an unavailable selection with another provider opens its ordinary // picker. The server's admission gate remains the belt. if (zeroKeys) { setSetupOpen(true); return; } const strong = catalog?.slots.strong?.model; if (selectedModelUnavailable(catalog, strong ? `${strong.provider}:${strong.id}` : undefined)) { openModelPicker(); return; // Preserve the draft and never create an orphan session. } startingRef.current = true; setBusy(true); setStartError(false); try { // The map context (owner, 2026-10-04): picks in EITHER mode ride // the CREATION as graphContext — the server writes a // graph-context marker as message[0] (a separate custom message // before the learner's first user message, propositions + the // chains among them); the prompt stays the learner's own words. const target = await starterRef.current.start({ title: t, ...(mode === "practice" ? { kind: "practice" } : {}), ...(practiceProps.length > 0 ? { graphContext: { propositions: practiceProps } } : {}), }, fetch, JSON.stringify(catalog?.slots.strong)); router.push(target); } catch { startingRef.current = false; setBusy(false); setStartError(true); } }; return (
{/* Window-drag strip (merged chrome, owner 2026-09-17): a dedicated mask element, deliberately FIRST in the DOM. Electron's app-region mask resolves by DOM order — a no-drag element subtracts only drag regions that came EARLIER in the tree — not by z-index, so the header's old inline drag region (after
) re-added itself over the graph chrome row's carve-out and the buttons dragged the window. Placed first, every carved control (chrome row, node detail card, the toggle) comes later and subtracts correctly. Pure mask: it never needs to receive events — the mask routes the pixels. */}