diff --git a/components/new-session.tsx b/components/new-session.tsx index ee8ec79..b9bc716 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -9,7 +9,13 @@ import { TopBar } from "@/components/top-bar"; import { SetupDialog } from "@/components/setup-dialog"; import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; import { StatsBar } from "@/components/stats-bar"; -import type { ChatSettings } from "@/src/core"; +import { ChainTableOverlay } from "@/components/chain-table.tsx"; +import { DumbbellIcon } from "lucide-react"; +import type { ChatSettings, GraphSnapshot } from "@/src/core"; + +/** The draft's pick set (owner round 10): the SAME shape the home pick + * bar lifts — resolved nodes as the learner SAW them at pick time. */ +export type DraftPick = { id: string; name: string; statement: string }; /** * The sessionless draft view at /chat/new (owner, 2026-09-13): "+ New @@ -37,13 +43,22 @@ export async function startDraftSession( text: string, fetchLike: typeof fetch, mode?: "practice", + picks?: readonly DraftPick[], ): Promise { const t = text.trim(); if (!t) return null; + // THE PICKS RIDE THE CREATE (owner round 10): a practice draft seeded + // from the pick bar creates its session WITH the graph context — the + // same contract as the home screen's practice send (the marker with + // [id] name: statement lines, target validation's truth set). const res = await fetchLike("/api/chats", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(mode === "practice" ? { title: t, kind: "practice" } : { title: t }), + body: JSON.stringify( + mode === "practice" + ? { title: t, kind: "practice", ...(picks && picks.length > 0 ? { graphContext: { propositions: picks } } : {}) } + : { title: t }, + ), }); const { id } = (await res.json()) as { id: string }; // No settings PUT on create (per-session models, owner 2026-09-28): @@ -63,6 +78,9 @@ export function DraftShell({ onSend, setupCta, mode = "teach", + picks = [], + onOpenPicker, + onClearPicks, }: { busy: boolean; text: string; @@ -76,6 +94,14 @@ export function DraftShell({ * affordance home carries, rendered above the draft shell — passed by * NewSession, which owns the configured state and the SetupDialog. */ setupCta?: ReactNode; + /** THE PICK BAR (owner round 10): the SAME pill the home screen + * carries above its composer — dashed "Pick propositions from the + * map" when empty, count + Clear when picked. The draft screen has + * no graph behind it, so the picker opens the standalone chain-table + * overlay over fetched snapshot data (owned by NewSession). */ + picks?: DraftPick[]; + onOpenPicker?: () => void; + onClearPicks?: () => void; }) { // Voice dictation (STT wave, 2026-09-17): finals append at the end of the // draft text (via the parent's onChange — the truth lives there); the @@ -144,6 +170,36 @@ export function DraftShell({
{setupCta} + {(picks.length > 0 || mode === "practice") && ( +
+ + {picks.length > 0 && ( + + )} +
+ )}
([]); + const picksRef = useRef([]); + const applyPicks = (next: DraftPick[]) => { + picksRef.current = next; + setPicks(next); + }; + const [pickerOpen, setPickerOpen] = useState(false); + const [snapshot, setSnapshot] = useState(null); + const openPicker = () => { + if (snapshot) { + setPickerOpen(true); + return; + } + void fetch("/api/graph") + .then((r) => r.json() as Promise) + .then((snap) => { + setSnapshot(snap); + setPickerOpen(true); + }) + .catch(() => { + /* an unreachable graph leaves the draft usable — pickless */ + }); + }; // First-run gate (model-slots wave, owner 2026-09-20): the send // INTERCEPTS into the setup flow while unconfigured — no defaults for @@ -223,7 +305,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi return; } setBusy(true); - void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined) + void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined) .then((target) => { if (target) router.push(target); else setBusy(false); @@ -239,6 +321,9 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi onChange={applyText} onSend={send} mode={mode} + picks={picks} + onOpenPicker={openPicker} + onClearPicks={() => applyPicks([])} setupCta={ zeroKeys ? ( setSetupOpen(true)} /> @@ -258,6 +343,29 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi ) } /> + {/* THE PICKER (owner round 10): the standalone chain table over + fetched snapshot data — the same select-mode overlay the home + graph hosts, no graph canvas needed behind it. Controlled + selection: every check/clear reports up live; Done closes. */} + setPickerOpen(false)} + selectMode + controlledSelection={new Set(picks.map((p) => p.id))} + onSelectionChange={(ids) => { + const byId = new Map((snapshot?.propositions ?? []).map((n) => [n.id, n])); + const next: DraftPick[] = []; + for (const id of ids) { + const n = byId.get(id); + if (n) next.push({ id: n.id, name: n.name, statement: n.statement }); + } + applyPicks(next); + }} + /> {/* First-run setup: the send interception's landing surface — stage 1 is the self-contained first-key screen (zero-keys wave). */} { assert.match(html, /What do you want to practice\?/); }); + it("THE PICK BAR (owner round 10): the practice draft carries the same pill above its composer", () => { + const html = renderToStaticMarkup( + {}} + onSend={() => {}} + mode="practice" + />, + ); + assert.match(html, /Pick propositions from the map/); + // picked: count + Clear, the home register verbatim + const picked = renderToStaticMarkup( + {}} + onSend={() => {}} + mode="practice" + picks={[{ id: "n_1", name: "Parity", statement: "Odd + odd = even." }]} + />, + ); + assert.match(picked, /1 proposition selected/); + assert.match(picked, /Clear/); + // teach mode with nothing picked renders nothing (home's law) + const teach = renderToStaticMarkup( + {}} onSend={() => {}} mode="teach" />, + ); + assert.ok(!teach.includes("Pick propositions from the map")); + }); + + it("startDraftSession carries the picks as graphContext (the home practice contract)", async () => { + let body: Record | null = null; + const f = (async (_url: string, init?: RequestInit) => { + body = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ id: "c10" }), { status: 201 }); + }) as unknown as typeof fetch; + await startDraftSession("drill me", f, "practice", [{ id: "n_1", name: "Parity", statement: "Odd + odd = even." }]); + assert.deepEqual(body, { + title: "drill me", + kind: "practice", + graphContext: { propositions: [{ id: "n_1", name: "Parity", statement: "Odd + odd = even." }] }, + }); + }); + + it("practice with NO picks stays lean — no graphContext key on the wire", async () => { + let body: Record | null = null; + const f = (async (_url: string, init?: RequestInit) => { + body = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ id: "c11" }), { status: 201 }); + }) as unknown as typeof fetch; + await startDraftSession("free practice", f, "practice", []); + assert.deepEqual(body, { title: "free practice", kind: "practice" }); + }); + it("startDraftSession carries kind:\"practice\" to the create route", async () => { let body: Record | null = null; const f = (async (_url: string, init?: RequestInit) => {