diff --git a/app/chat/new/page.tsx b/app/chat/new/page.tsx index 6b06c56..d01a73c 100644 --- a/app/chat/new/page.tsx +++ b/app/chat/new/page.tsx @@ -15,8 +15,16 @@ export const dynamic = "force-dynamic"; * default); the session the first send creates is stamped from it * server-side (per-session models, owner 2026-09-28). */ -export default function NewSessionPage() { +export default async function NewSessionPage({ + searchParams, +}: { + searchParams: Promise<{ mode?: string }>; +}) { + const { mode } = await searchParams; return ( - + ); } diff --git a/components/chain-table.tsx b/components/chain-table.tsx index d333e05..b1e7538 100644 --- a/components/chain-table.tsx +++ b/components/chain-table.tsx @@ -59,6 +59,7 @@ import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, + DumbbellIcon, FolderIcon, FolderOpenIcon, Trash2Icon, @@ -113,6 +114,8 @@ export function ChainTableBody({ onClear, onDelete, onClose, + selectMode, + onSelect, }: { nodes: GraphNode[]; chains: GraphChain[]; @@ -134,6 +137,11 @@ export function ChainTableBody({ /** Opens the full-death confirm dialog (owner, 2026-09-29 night). */ onDelete: () => void; onClose: () => void; + /** PRACTICE SELECT (2026-10-04): the floating bar's confirm becomes + * "Practice these" — no delete fence; the caller owns what the + * selection becomes. */ + selectMode?: boolean; + onSelect?: () => void; }) { const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); const chains = useMemo(() => chainsForSelection(nodes, storedChains), [nodes, storedChains]); @@ -575,6 +583,11 @@ export function ChainTableOverlay({ sessions, fileSources, onClose, + /** PRACTICE SELECT (2026-10-04): the table opens as a proposition + * picker — the floating bar confirms the selection to the caller + * (never deletes); the title says what the pick is for. */ + selectMode, + onConfirmSelection, }: { open: boolean; nodes: GraphNode[]; @@ -582,6 +595,8 @@ export function ChainTableOverlay({ sessions: Record; fileSources: Record; onClose: () => void; + selectMode?: boolean; + onConfirmSelection?: (nodes: GraphNode[]) => void; }) { const [query, setQuery] = useState(""); const [sort, setSort] = useState({ col: "name", dir: 1 }); @@ -654,6 +669,12 @@ export function ChainTableOverlay({ onClear={() => setSelection(new Set())} onDelete={() => setDeleteOpen(true)} onClose={onClose} + selectMode={selectMode} + onSelect={() => { + onConfirmSelection?.(selectedNodes); + setSelection(new Set()); + onClose(); + }} /> {deleteOpen && ( diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 2cf337b..035266b 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -944,7 +944,18 @@ export function GraphChromeRow({ ); } -export function GraphView() { +export function GraphView({ + chainSelectOpen = false, + onChainSelected, + onChainSelectClose, +}: { + /** PRACTICE SELECT (owner, 2026-10-04): the home composer's practice + * chip opens the chain table as a proposition picker — this prop + * drives it; the confirm hands the picked nodes to the caller. */ + chainSelectOpen?: boolean; + onChainSelected?: (nodes: { id: string; name: string; statement: string }[]) => void; + onChainSelectClose?: () => void; +}) { const containerRef = useRef(null); const canvasRef = useRef(null); const cardRef = useRef(null); @@ -1048,6 +1059,12 @@ export function GraphView() { // The chain table overlay (chains-overlay plan, 2026-09-29) — // component-local: a browsing surface, not graph state. const [chainsOpen, setChainsOpen] = useState(false); + // PRACTICE SELECT (2026-10-04): the parent's chip drives the select + // mode; closing (either the picker's confirm or its X) reports back so + // the chip's open state and the table's never disagree. + useEffect(() => { + if (chainSelectOpen) setChainsOpen(true); + }, [chainSelectOpen]); useEffect(() => { let cancelled = false; fetch("/api/graph") @@ -1678,7 +1695,15 @@ export function GraphView() { chains={snapshot?.chains ?? []} sessions={snapshot?.sessions ?? {}} fileSources={snapshot?.fileSources ?? {}} - onClose={() => setChainsOpen(false)} + onClose={() => { + setChainsOpen(false); + onChainSelectClose?.(); + }} + selectMode={chainSelectOpen} + onConfirmSelection={(picked) => { + onChainSelected?.(picked.map((n) => ({ id: n.id, name: n.name, statement: n.statement }))); + onChainSelectClose?.(); + }} /> ); } diff --git a/components/home.tsx b/components/home.tsx index 8491cc1..bbb28d0 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import { CornerDownLeftIcon } from "lucide-react"; +import { CornerDownLeftIcon, DumbbellIcon } from "lucide-react"; import { GraphView } from "@/components/graph-view"; import { SetupDialog } from "@/components/setup-dialog"; import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; @@ -65,6 +65,15 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) { } }, [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); + /* 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 @@ -101,16 +110,26 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) { } setBusy(true); try { + // The practice seed: the picks ride the OPENING PROMPT (never + // the title — the title stays the learner's own words). + const seed = + mode === "practice" && practiceProps.length > 0 + ? `\n\nPropositions from the map:\n${practiceProps + .map((p) => `- ${p.name}: ${p.statement}`) + .join("\n")}` + : ""; const res = await fetch("/api/chats", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: t }), + body: JSON.stringify( + mode === "practice" ? { title: t, kind: "practice" } : { title: t }, + ), }); const { id } = (await res.json()) as { id: string }; // No settings PUT on create (per-session models, owner // 2026-09-28): the SERVER stamps the newborn from the strong // slot — the same default this surface's model control edits. - router.push(`/chat/${id}?prompt=${encodeURIComponent(t)}`); + router.push(`/chat/${id}?prompt=${encodeURIComponent(t + seed)}`); } catch { setBusy(false); } @@ -133,7 +152,17 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) { padding on any side; the canvas runs from the sidebar to the right edge and from the bottom to the top, under everything else. */}
- + { + setPracticeProps((prev) => { + const byId = new Map(prev.map((p) => [p.id, p])); + for (const p of picked) byId.set(p.id, p); + return [...byId.values()]; + }); + }} + onChainSelectClose={() => setChainSelectOpen(false)} + />
{/* Header floats over the graph — pointer-events-none except the @@ -153,6 +182,25 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) { px-4) so nothing shifts when a session starts (owner). */}
+ {/* The practice chip (owner, 2026-10-04): the graph-view + entry — a count of the picked propositions, click opens + the chain table as the picker. Quiet, left-aligned above + the composer; teaching mode renders nothing. */} + {mode === "practice" && ( +
+ +
+ )} {zeroKeys ? ( setSetupOpen(true)} /> ) : ( @@ -198,7 +246,7 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) { void start(); } }} - placeholder="What do you want to learn?" + placeholder={mode === "practice" ? "What do you want to practice?" : "What do you want to learn?"} autoFocus rows={1} className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground" @@ -221,6 +269,27 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) { } turns={[] as TurnUsage[]} + leadingSlot={ +
+ {(["teach", "practice"] as const).map((m) => ( + + ))} +
+ } />
diff --git a/components/new-session.tsx b/components/new-session.tsx index 9abfd76..ee8ec79 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -36,13 +36,14 @@ const DOCK = "mx-auto w-full max-w-[46rem] px-4"; export async function startDraftSession( text: string, fetchLike: typeof fetch, + mode?: "practice", ): Promise { const t = text.trim(); if (!t) return null; const res = await fetchLike("/api/chats", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: t }), + body: JSON.stringify(mode === "practice" ? { title: t, kind: "practice" } : { title: t }), }); const { id } = (await res.json()) as { id: string }; // No settings PUT on create (per-session models, owner 2026-09-28): @@ -61,11 +62,16 @@ export function DraftShell({ onChange, onSend, setupCta, + mode = "teach", }: { busy: boolean; text: string; onChange: (v: string) => void; onSend: () => void; + /** The practice draft (owner, 2026-10-04): ?mode=practice — the + * created session carries kind:"practice" and the copy changes + * (the practice agent opens; no probing). */ + mode?: "teach" | "practice"; /** The first-run CTA (fix wave chunk B): the same "Set up your models" * affordance home carries, rendered above the draft shell — passed by * NewSession, which owns the configured state and the SetupDialog. */ @@ -112,7 +118,9 @@ export function DraftShell({ element, every surface — h-15, drag, sidebar reopen, lights gutter. h-15 (60px) matches the sidebar's wordmark band. */} -
New session
+
+ {mode === "practice" ? "New practice session" : "New session"} +
@@ -123,8 +131,12 @@ export function DraftShell({ @@ -147,7 +159,7 @@ export function DraftShell({ // input (owner refinement round, 2026-09-24) — no lesson has // started yet, so the composer must not read like one is in // flight. - placeholder="What do you want to learn?" + placeholder={mode === "practice" ? "What do you want to practice?" : "What do you want to learn?"} />
{ if (target) router.push(target); else setBusy(false); @@ -226,6 +238,7 @@ export function NewSession({ initialConfigured }: { initialConfigured: boolean } text={text} onChange={applyText} onSend={send} + mode={mode} setupCta={ zeroKeys ? ( setSetupOpen(true)} /> diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 765ad12..43bed5b 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -6,7 +6,7 @@ import { usePathname, useRouter } from "next/navigation"; import { MenuShell } from "@/components/menu-shell"; import { DeleteSessionDialog } from "@/components/delete-session-dialog"; import type { ChatDeleteMode } from "@/src/core"; -import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon, SettingsIcon } from "lucide-react"; +import { DumbbellIcon, PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon, SettingsIcon } from "lucide-react"; import { ActivitySection } from "@/components/activity-dock"; import { CoverageMenuBlock } from "@/components/extraction-coverage"; import { LhMark } from "@/components/lh-mark"; @@ -227,7 +227,7 @@ export function timeAgo(iso: string, now = new Date()): string { * ever wanted. */ function sessionMeta(chat: ChatListItem): string { if (chat.pendingQuestion) return `Waiting for your answer · ${timeAgo(chat.updatedAt)}`; - if (chat.busy) return "Teacher is working…"; + if (chat.busy) return (chat.kind ?? "teach") === "practice" ? "Practicing…" : "Teacher is working…"; return timeAgo(chat.updatedAt); } @@ -265,6 +265,9 @@ export function SessionRow({ )} > + {(chat.kind ?? "teach") === "practice" && ( + + )} {/* Titles are the learner's/teacher's WORDS — Source Sans 3, the same voice the words were typed in (every input is lh-prose); @@ -328,10 +331,25 @@ export function RenameRow({ chat, onCommit, onCancel }: { chat: ChatListItem; on ); } +/** The three-way kind filter (practice sessions, owner 2026-10-04): + * Teaching / All / Practice — a client-side filter over the same + * snapshot, persisted in localStorage, defaulting to All. */ +export type SessionKindFilter = "teach" | "all" | "practice"; +const KIND_FILTER_KEY = "lh:session-kind-filter"; + function SessionsSidebar() { const { open, toggle } = useSidebar(); const [chats, setChats] = useState(null); const [query, setQuery] = useState(""); + const [kindFilter, setKindFilter] = useState("all"); + useEffect(() => { + const stored = window.localStorage.getItem(KIND_FILTER_KEY); + if (stored === "teach" || stored === "all" || stored === "practice") setKindFilter(stored); + }, []); + const setFilter = useCallback((f: SessionKindFilter) => { + setKindFilter(f); + window.localStorage.setItem(KIND_FILTER_KEY, f); + }, []); const pathname = usePathname(); const router = useRouter(); const { open: openSettings } = useSettingsDialog(); @@ -346,10 +364,14 @@ function SessionsSidebar() { const shown = useMemo(() => { const q = query.trim().toLowerCase(); - if (!chats) return chats; - if (!q) return chats; - return chats.filter((c) => c.title.toLowerCase().includes(q)); - }, [chats, query]); + let list = chats; + if (list && kindFilter !== "all") { + list = list.filter((c) => (c.kind ?? "teach") === kindFilter); + } + if (!list) return list; + if (!q) return list; + return list.filter((c) => c.title.toLowerCase().includes(q)); + }, [chats, query, kindFilter]); const refreshInFlightRef = useRef(false); const refresh = useCallback(async () => { @@ -644,16 +666,51 @@ function SessionsSidebar() { - + {/* Two doors (practice sessions, owner 2026-10-04): teaching + keeps the original draft route; practice carries its mode. */} +
+ + +
+ + {/* The three-way filter (owner, 2026-10-04): Teaching / All / + Practice, quiet segmented, persisted client-side. */} +
+ {(["teach", "all", "practice"] as const).map((f) => ( + + ))} +
Sessions diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx index d5be512..cd4cc25 100644 --- a/components/stats-bar.tsx +++ b/components/stats-bar.tsx @@ -30,6 +30,7 @@ export function StatsBar({ turns, dictationSlot, chatId, + leadingSlot, }: { turns: TurnUsage[]; /** The bound session (per-session models, owner 2026-09-28): present @@ -42,6 +43,10 @@ export function StatsBar({ * button lives there, and while recording the meter unfurls to fill * whatever width the row has (owner placement). */ dictationSlot?: ReactNode; + /** The row's LEFT edge (practice sessions, 2026-10-04): the home + * composer's teach | practice switch rides here — before the session + * data (which renders nothing pre-chat on that surface). */ + leadingSlot?: ReactNode; }) { const [open, setOpen] = useState(false); // Same dropdown discipline as the model control (owner, df4433f treatment, @@ -94,6 +99,9 @@ export function StatsBar({ outside-close ref anchors HERE too (owner, round 3): on the whole bar it wrapped the model control, so clicking model/thinking never counted as outside — the asymmetry the owner observed. */} + {/* The leading slot (2026-10-04): the leftmost control surface — + the home composer's mode switch. */} + {leadingSlot} {/* The left side: the session-data summary — only once a session exists (turns). Pre-chat renders nothing (see the header). */} {turns.length > 0 && ( diff --git a/tests/render-new-session.test.tsx b/tests/render-new-session.test.tsx index c8141be..1a8dd90 100644 --- a/tests/render-new-session.test.tsx +++ b/tests/render-new-session.test.tsx @@ -107,4 +107,41 @@ describe("DraftShell — the sessionless surface", () => { ); assert.ok(!/\/chat\/c[a-z0-9_]+/.test(html)); }); -}); \ No newline at end of file +}); +describe("the practice draft (?mode=practice, 2026-10-04)", () => { + it("the shell swaps copy: practice title, empty state, placeholder", () => { + const html = renderToStaticMarkup( + {}} + onSend={() => {}} + mode="practice" + />, + ); + assert.match(html, /New practice session/); + assert.match(html, /Describe what to practice/); + assert.match(html, /What do you want to practice\?/); + }); + + it("startDraftSession carries kind:\"practice\" to the create route", 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: "c9" }), { status: 201 }); + }) as unknown as typeof fetch; + const target = await startDraftSession("the parity chain", f, "practice"); + assert.equal(target, "/chat/c9?prompt=the%20parity%20chain"); + assert.deepEqual(body, { title: "the parity chain", kind: "practice" }); + }); + + it("the teaching default is unchanged — no kind 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: "c8" }), { status: 201 }); + }) as unknown as typeof fetch; + await startDraftSession("orbital mechanics", f); + assert.deepEqual(body, { title: "orbital mechanics" }); + }); +}); diff --git a/tests/render-sidebar.test.tsx b/tests/render-sidebar.test.tsx index 5b6b103..da0e8b8 100644 --- a/tests/render-sidebar.test.tsx +++ b/tests/render-sidebar.test.tsx @@ -117,3 +117,22 @@ describe("timeAgo", () => { assert.equal(timeAgo("2026-01-02T12:01:00.000Z", now), "just now"); }); }); + +describe("SessionRow practice badge (practice sessions, 2026-10-04)", () => { + it("a practice row carries the dumbbell badge and the Practicing meta while busy", () => { + const html = render(item({ kind: "practice", busy: true })); + assert.match(html, /Practicing…/); + assert.match(html, /aria-label="Practice session"/); + }); + + it("a teaching row carries neither", () => { + const html = render(item({})); + assert.doesNotMatch(html, /Practice session/); + assert.doesNotMatch(html, /Practicing…/); + }); + + it("busy teaching rows still say the teacher is working", () => { + const html = render(item({ busy: true })); + assert.match(html, /Teacher is working…/); + }); +});