diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts index cb4c31b..7ac8e5a 100644 --- a/app/api/chats/route.ts +++ b/app/api/chats/route.ts @@ -1,7 +1,16 @@ -import { createChat, listChats, titleFromPrompt } from "@/src/server"; +import { createChat, isTurnRunning, listChats, listJobs, titleFromPrompt } from "@/src/server"; +import type { ChatListItem } from "@/src/core"; +/** The session picker's list: record-derived summaries (phase, pending + * question) + live liveness — a running turn OR a background job marks + * the session busy (contracts.ts ChatListItem; the store can't see + * liveness, the route is the merge point). */ export async function GET() { - return Response.json({ chats: listChats() }); + const chats: ChatListItem[] = listChats().map((c) => ({ + ...c, + busy: isTurnRunning(c.id) || listJobs(c.id).some((j) => j.status === "running"), + })); + return Response.json({ chats }); } export async function POST(req: Request) { diff --git a/app/globals.css b/app/globals.css index a123a02..7c824ce 100644 --- a/app/globals.css +++ b/app/globals.css @@ -157,3 +157,55 @@ body { overflow-x: auto; overflow-y: hidden; } + +/* ============================================================ + * Session status — phase colors + status dots + * (picker strips, header chip, transcript dividers: one language) + * ============================================================ */ + +/* Phase colors: blue = probing, violet = planning, green = teaching. + Tuned per theme (light needs darker, dark lighter). These color the + session dots, header chip and transcript dividers — one language. */ +:root { + --phase-probe: oklch(0.55 0.17 253); + --phase-plan: oklch(0.54 0.19 300); + --phase-teach: oklch(0.55 0.14 160); +} + +.dark { + --phase-probe: oklch(0.72 0.13 250); + --phase-plan: oklch(0.72 0.14 300); + --phase-teach: oklch(0.74 0.12 160); +} + +@theme inline { + --color-phase-probe: var(--phase-probe); + --color-phase-plan: var(--phase-plan); + --color-phase-teach: var(--phase-teach); +} + +/* Session dot motion (picker): the ONLY animated state is "teacher is + working" — a quiet phase-colored breathing. Pending questions are the + dominant resting state and stay perfectly still (solid dot). */ +@keyframes lh-dot-breathe { + 0%, + 100% { + opacity: 0.35; + transform: scale(0.75); + } + + 50% { + opacity: 1; + transform: scale(1); + } +} + +.lh-dot-breathe { + animation: lh-dot-breathe 1.4s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .lh-dot-breathe { + animation: none; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 201bc87..f8c1df1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import "katex/dist/katex.min.css"; import "./globals.css"; import { AppShell, SidebarProvider } from "@/components/sidebar"; +import { TreePanelModeProvider } from "@/components/tree-panel"; export const metadata: Metadata = { title: "Learning Harness", @@ -13,7 +14,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - {children} + + {children} + diff --git a/components/artifacts/index.ts b/components/artifacts/index.ts index bf6f87e..dc6eea2 100644 --- a/components/artifacts/index.ts +++ b/components/artifacts/index.ts @@ -2,7 +2,6 @@ import type { ComponentType } from "react"; import type { ArtifactPartProps } from "@/src/core"; import { DemoCard } from "@/components/artifacts/demo-card"; import { JobCard } from "@/components/artifacts/job-card"; -import { PlanDivider } from "@/components/artifacts/plan-divider"; import { VisualCard } from "@/components/artifacts/visual-card"; /** @@ -19,8 +18,9 @@ import { VisualCard } from "@/components/artifacts/visual-card"; */ export const artifactRenderers: Record> = { research: JobCard, - // Phase divider where the plan lands (W5 fills in the component): - plan_init: PlanDivider, + // plan_* parts render nothing inline by design: the phase seam is a + // message-level boundary, drawn by PhaseDivider in chat.tsx (the old + // part-level plan_init divider retired with it). // Artifacts (W6 fills in the components): demo: DemoCard, visual: VisualCard, diff --git a/components/artifacts/plan-divider.tsx b/components/artifacts/plan-divider.tsx deleted file mode 100644 index e3ac6fd..0000000 --- a/components/artifacts/plan-divider.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { ArtifactPartProps, PlanToolResult } from "@/src/core"; - -/** - * The probe→teach divider (vision §2 "Two phases, clearly separated"). - * - * Rendered by the artifact registry wherever the session's plan_init part - * sits in the transcript — the plan call IS the transition marker, so the - * divider marks the seam: probing above, teaching below. Deliberately quiet: - * a hairline rule with a small chip, never a banner. - * - * Renders null until the tool's output is available AND the init succeeded — - * a failed first plan_init must not mark the seam (the retry that succeeds - * draws the divider at its own, correct spot). - */ -export function PlanDivider({ part }: ArtifactPartProps) { - if (part.state !== "output-available") return null; - const result = part.output as PlanToolResult | undefined; - if (!result || result.ok !== true) return null; - const nodeCount = result.tree?.nodes?.length ?? 0; - return ( -
- - - Teaching{nodeCount > 0 ? ` · ${nodeCount} node${nodeCount === 1 ? "" : "s"}` : ""} - - -
- ); -} diff --git a/components/chat.tsx b/components/chat.tsx index 2f1557e..d50e312 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { createContext, Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai"; import { useStickToBottomContext } from "use-stick-to-bottom"; @@ -24,7 +24,8 @@ import { SidebarToggle } from "@/components/sidebar"; import { SubAgentsPanel } from "@/components/sub-agents-panel"; import { TreePanel } from "@/components/tree-panel"; import type { ArtifactPartProps, JobRecord, QuestionAnswerData, TeacherUIMessage } from "@/src/core"; -import { findPendingToolCall, phaseAt } from "@/src/core"; +import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider"; +import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core"; /** One width for everything: transcript, cards, and the bottom blob share this column. */ const COLUMN = "mx-auto w-full max-w-3xl px-4"; @@ -127,13 +128,18 @@ export function Chat({ const localStream = status === "submitted" || status === "streaming"; const busy = localStream || turnInFlight; - // Teach-phase check (W5, vision §2 "two phases"): a pending question that - // lands after plan_init starts collapsed behind a "Check your - // understanding" reveal button; probe-phase questions auto-open as today. - // A pending question can never precede plan_init in the transcript (the - // teacher's turn ends at the question call), so the session's current - // phase IS the question's phase. Pre-plan sessions: all probe — unchanged. - const requiresReveal = pending !== null && phaseAt(messages) === "teach"; + // 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 writing a question. // Shown as a transcript shimmer, exactly like the thinking indicator. @@ -267,16 +273,23 @@ export function Chat({ return ( void sendMessage({ text: t }) }}> -
-
- -
{title}
-
+ {/* 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 stays sticky inside the scroller, untouched. */} +
+
+ {/* h-10, 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. */} +
+ +
{title}
+ +
- {/* The session row: transcript scroller + the persistent plan-tree panel - (W7, vision §2). The panel is a flex SIBLING of the scroller — the dock - stays sticky inside the scroller, untouched. */} -
+
@@ -296,7 +309,11 @@ export function Chat({ // can still hold empty ids — the index keeps keys unique for them. ) : ( - + + {mi > 0 && phases[mi] !== phases[mi - 1] && ( + + )} + {m.role === "user" ? ( // User text renders plain: the model's LaTeX pipeline is not @@ -342,7 +359,8 @@ export function Chat({ }) )} - + + ))} {(status === "submitted" || turnInFlight) && Thinking…} @@ -381,11 +399,13 @@ export function Chat({ - {/* Right sidebar: live sub-agent fleet (when running) above the plan tree. */} + {/* Right sidebar: the live sub-agent fleet (when running) above + the plan tree — the fleet panel renders nothing while idle. */}
+
diff --git a/components/phase-divider.tsx b/components/phase-divider.tsx new file mode 100644 index 0000000..0e9697b --- /dev/null +++ b/components/phase-divider.tsx @@ -0,0 +1,99 @@ +import { cn } from "@/lib/utils"; +import type { Phase3 } from "@/src/core"; + +/** + * The in-session phase presentation — the explicit face of what the picker + * shows as a color strip (one color system everywhere, globals.css + * --phase-*): the header chip next to the session title, and the dotted + * boundary rule the transcript draws where the session moves into a new + * phase. Replaces the old inline plan-divider (artifact registry): the + * boundary is a MESSAGE-level seam, so chat.tsx renders it at the message + * where the phase changes, not inside a tool part. + * + * Quiet register (matches the old divider): hairline rules, small chips — + * never a banner. Probe never renders a divider: phases only advance, so + * probe is the start of the transcript and "Planning"/"Teaching" are the two + * visible seams. + */ + +/** Header-chip labels: the phases as the learner thinks of them. */ +const CHIP_LABEL: Record = { + probe: "Probing", + plan: "Planning", + teach: "Teaching", +}; + +const PHASE_DOT: Record = { + probe: "bg-phase-probe", + plan: "bg-phase-plan", + teach: "bg-phase-teach", +}; + +/** The header chip: a phase-colored dot + label next to the session title. */ +export function PhaseChip({ phase }: { phase: Phase3 }) { + return ( + + + {CHIP_LABEL[phase]} + + ); +} + +/** Divider rule + chip colors per phase (probe never reaches here). */ +const DIVIDER_RULE: Partial> = { + plan: "border-phase-plan/50", + teach: "border-phase-teach/50", +}; +const DIVIDER_TEXT: Partial> = { + plan: "border-phase-plan/50 text-phase-plan", + teach: "border-phase-teach/50 text-phase-teach", +}; + +/** + * The transcript boundary: a color-coded dotted rule at the message where + * the session enters `phase`. The plan seam carries its node count ("Plan · + * 4 nodes"); teaching is just the label. Null for probe (defensive — the + * fold never emits a probe transition after the start). + */ +export function PhaseDivider({ phase, nodeCount }: { phase: Phase3; nodeCount?: number }) { + if (phase === "probe") return null; + const label = + phase === "plan" && nodeCount + ? `Plan · ${nodeCount} node${nodeCount === 1 ? "" : "s"}` + : CHIP_LABEL[phase]; + return ( +
+ + + {label} + + +
+ ); +} + +/** + * The node count for a plan seam: the first plan-op tree output in the + * message that carries the seam (the plan_init that made the phase flip). + * Structural slice of a UI message, same shape-of-shapes as core/phases.ts. + */ +export function planNodeCount( + message: { parts: ReadonlyArray<{ type: string; state?: string; output?: unknown; [k: string]: unknown }> }, +): number | undefined { + for (const part of message.parts) { + if (!part.type.startsWith("tool-plan_") || part.state !== "output-available") continue; + const tree = (part.output as { tree?: { nodes?: unknown[] } } | undefined)?.tree; + if (tree && Array.isArray(tree.nodes) && tree.nodes.length > 0) return tree.nodes.length; + } + return undefined; +} diff --git a/components/sidebar.tsx b/components/sidebar.tsx index d705b13..8eeb6c0 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -6,7 +6,7 @@ import { usePathname, useRouter } from "next/navigation"; import { PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import type { ChatSummary } from "@/src/core"; +import type { ChatListItem, Phase3 } from "@/src/core"; /** * Toggleable sidebar — NOT a modal drawer: no backdrop, no focus theft. It @@ -65,23 +65,147 @@ export function SidebarToggle() { ); } +/** Phase colors for the session dot — one system with the header chip + * and the transcript dividers (globals.css --phase-*). Static class + * strings per phase: Tailwind can't compile dynamic class names. */ +const PHASE_FILL: Record = { + probe: "bg-phase-probe", + plan: "bg-phase-plan", + teach: "bg-phase-teach", +}; +const PHASE_RING: Record = { + probe: "border-phase-probe", + plan: "border-phase-plan", + teach: "border-phase-teach", +}; + +/** + * The session dot, left of the title. TWO channels, one dot: COLOR is the + * phase (probe/plan/teach — sky/violet/emerald), FILL+MOTION is what's + * owed. Pulsing = the teacher is working (rare, time-sensitive — the only + * state that deserves motion). Solid = a question awaits the learner's + * answer (the DOMINANT resting state of real sessions — calm by design, + * no animation). Hollow = nothing owed, idle. A pending answer outranks + * "working": if a question is owed, that's the blocking fact. + */ +function SessionDot({ phase, pending, busy }: { phase: Phase3; pending: boolean; busy: boolean }) { + if (pending) { + return ( + + Waiting for your answer + + ); + } + if (busy) { + return ( + + Teacher is working + + ); + } + return ( + + ); +} + +/** Compact "how long ago": "just now", "5m ago", "3h ago", "2d ago", + * then a plain date. How long a session has been WAITING matters more + * than an absolute timestamp. Pure — exported for tests. */ +export function timeAgo(iso: string, now = new Date()): string { + const ms = now.getTime() - new Date(iso).getTime(); + if (ms < 60_000) return "just now"; + const min = Math.floor(ms / 60_000); + if (min < 60) return `${min}m ago`; + const h = Math.floor(min / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 7) return `${d}d ago`; + return new Date(iso).toLocaleDateString(); +} + +/** The row's second line: state replaces the message count when the state + * is the more useful fact (most resting sessions are awaiting an answer — + * that's what the learner scans for). */ +function sessionMeta(chat: ChatListItem): string { + if (chat.pendingQuestion) return `Waiting for your answer · ${timeAgo(chat.updatedAt)}`; + if (chat.busy) return "Teacher is working…"; + return `${chat.messageCount} messages · ${new Date(chat.updatedAt).toLocaleDateString()}`; +} + +/** + * One session row: the status dot, then title + state-aware meta. Exported + * for render tests — pure function of the list item. + */ +export function SessionRow({ + chat, + active, + onOpen, +}: { + chat: ChatListItem; + active: boolean; + onOpen: () => void; +}) { + return ( + + ); +} + function SessionsSidebar() { const { open, toggle } = useSidebar(); - const [chats, setChats] = useState(null); + const [chats, setChats] = useState(null); const pathname = usePathname(); const router = useRouter(); + const refresh = useCallback(async () => { + try { + const res = await fetch("/api/chats", { cache: "no-store" }); + const d = (await res.json()) as { chats: ChatListItem[] }; + setChats(d.chats); + } catch { + // a transient failure on the periodic poll keeps the last snapshot + setChats((prev) => prev ?? []); + } + }, []); + useEffect(() => { if (!open) return; - let cancelled = false; - fetch("/api/chats", { cache: "no-store" }) - .then((r) => r.json()) - .then((d: { chats: ChatSummary[] }) => !cancelled && setChats(d.chats)) - .catch(() => !cancelled && setChats([])); - return () => { - cancelled = true; - }; - }, [open, pathname]); + void refresh(); + // Poll while open: the statuses are live (a question lands in another + // session, a turn finishes in the background) and the payload is tiny. + // Pause while the tab is hidden; navigation refetches via pathname. + const id = setInterval(() => { + if (!document.hidden) void refresh(); + }, 2500); + return () => clearInterval(id); + }, [open, pathname, refresh]); return (
) : ( - chats.map((c) => { - const active = pathname === `/chat/${c.id}`; - return ( - - ); - }) + chats.map((c) => ( + router.push(`/chat/${c.id}`)} + /> + )) )}
diff --git a/components/tree-panel.tsx b/components/tree-panel.tsx index 7865c1b..fe2ee86 100644 --- a/components/tree-panel.tsx +++ b/components/tree-panel.tsx @@ -1,7 +1,15 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import { CheckIcon, ChevronsRightIcon, ListTreeIcon } from "lucide-react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { + CheckIcon, + ChevronsRightIcon, + ListTreeIcon, + MaximizeIcon, + MinusIcon, + NetworkIcon, + PlusIcon, +} from "lucide-react"; import { cn } from "@/lib/utils"; import { planNodeStatus, type PlanNode, type PlanNodeStatus, type PlanTree } from "@/src/core"; @@ -17,17 +25,35 @@ import { planNodeStatus, type PlanNode, type PlanNodeStatus, type PlanTree } fro * set_current / mark_done outputs flip the panel mid-session. * * The panel and the agent's tools share one object with two faces (agent -* edits it, UI renders it) — the divider in the transcript (plan-divider) -* marks the seam; THIS panel is the surface for every other plan_* part -* (they render nothing inline, by design). +* edits it, UI renders it) — the phase seam in the transcript +* (PhaseDivider, message-level) marks the transition; THIS panel is the +* surface for every plan_* part (they render nothing inline, by design). * -* Quiet register (matches plan-divider): hairline border, muted text, one -* highlighted row for the current position. Collapses to a thin rail; the -* preference persists in localStorage. Hidden below `xl` (CSS-only). +* Three modes, one persisted preference: the LIST (nested — the default, +* and the always-visible "collapsed" view), the GRAPH (expanded: a +* hand-built mermaid-style diagram — full styling control, no mermaid +* renderer — with drag-pan + wheel-zoom whose FLOOR is fit-to-panel), and +* the RAIL (thin strip). Quiet register throughout (matches the phase +* dividers): hairline borders, muted text, one highlighted node for the +* current position. Hidden below `xl` (CSS-only). */ -/** localStorage key for the collapse preference (matches lh:sidebar-open). */ -const STORAGE_KEY = "lh:tree-panel-collapsed"; +/** localStorage: the mode persists as "lh:tree-panel-mode" + * ("rail" | "list" | "graph"); the pre-graph key + * ("lh:tree-panel-collapsed": "1" rail / "0" list) is the fallback. */ +/** The panel width per mode — the aside AND its fixed inner layer (see + * TreePanel: the inner layer must not squish while the aside animates). */ +const PANEL_WIDTH: Record = { + rail: "w-10", + list: "w-64", + graph: "w-96", +}; + +const MODE_KEY = "lh:tree-panel-mode"; +const LEGACY_KEY = "lh:tree-panel-collapsed"; + +/** The three panel modes: rail (fully collapsed) | list (default) | graph. */ +type PanelMode = "rail" | "list" | "graph"; /** * Minimal structural slice of a UI message — anything with typed parts. @@ -140,49 +166,94 @@ export function nextNodeId( // ─── panel ─────────────────────────────────────────────────────────────────── +/** + * The panel mode lives in the ROOT layout, not in TreePanel. TreePanel is + * per-page (chat sessions remount it on every navigation), so a local copy + * would paint its default first and then "correct" to the stored mode — + * with the animated frame, opening a session visibly opened-then-closed + * the panel. In the root layout the mode survives navigations untouched, + * exactly like the left sidebar's open state (SidebarProvider). + */ +const TreePanelModeContext = createContext<{ mode: PanelMode; go: (next: PanelMode) => void }>({ + mode: "list", + go: () => {}, +}); + +/** Root-layout persistence of the panel mode (rail | list | graph). */ +export function TreePanelModeProvider({ children }: { children: React.ReactNode }) { + // SSR default: "list" — server and client first-render agree (no + // hydration mismatch). The stored preference applies on mount, and + // the provider itself never remounts, so after that first correction + // the mode is simply THERE on every session you open. + const [mode, setMode] = useState("list"); + + useEffect(() => { + try { + const stored = window.localStorage.getItem(MODE_KEY); + if (stored === "rail" || stored === "list" || stored === "graph") { + setMode(stored); + } else { + // legacy key (pre-graph): "1" = the collapsed rail + const legacy = window.localStorage.getItem(LEGACY_KEY); + if (legacy === "1") setMode("rail"); + } + } catch { + // private mode / storage disabled — the preference is simply not persisted + } + }, []); + + const go = useCallback((next: PanelMode) => { + setMode(next); + try { + window.localStorage.setItem(MODE_KEY, next); + } catch { + // see above + } + }, []); + + return {children}; +} + export function TreePanel({ messages, }: { messages: readonly TreePanelMessage[]; }) { const tree = useMemo(() => latestTree(messages), [messages]); - - // Start expanded so server and client first-render agree (no hydration - // mismatch); the stored preference is applied after mount. Same pattern - // as the sidebar. - const [collapsed, setCollapsed] = useState(false); - - useEffect(() => { - try { - const stored = window.localStorage.getItem(STORAGE_KEY); - if (stored !== null) setCollapsed(stored === "1"); - } catch { - // private mode / storage disabled — the preference is simply not persisted - } - }, []); - - const toggle = () => { - const next = !collapsed; - setCollapsed(next); - try { - window.localStorage.setItem(STORAGE_KEY, next ? "1" : "0"); - } catch { - // see above - } - }; + const { mode, go } = useContext(TreePanelModeContext); // Empty state: no tree yet → the panel does not render at all. It appears // the moment plan_init's output lands and updates live from there. if (!tree || tree.nodes.length === 0) return null; - return collapsed ? ( - - ) : ( - + // ONE animated container, exactly like the left sidebar: the aside + // animates its width (sidebar-motion transition cascades from the + // provider and is mount-gated there) while the inner layer keeps a + // FIXED width and is clipped by overflow-hidden — content never + // reflows mid-transition, so no scrollbar flash or overshoot. The + // mode's content just swaps instantly inside the steady frame. + return ( + ); } @@ -197,9 +268,9 @@ export function TreePanelRail({ onExpand: () => void; }) { return ( - + ); } -/** The expanded panel: header + the node list in topological order. */ +/** The expanded panel (list mode): header + the node list in topological order. */ export function TreePanelBody({ tree, + onShowGraph, onCollapse, }: { tree: PlanTree; + /** Expand into the mermaid-style graph view. */ + onShowGraph: () => void; onCollapse: () => void; }) { const { order, depth } = useMemo(() => topoLayout(tree), [tree]); @@ -234,10 +308,7 @@ export function TreePanelBody({ const totalCount = tree.nodes.length; return ( - + ); } @@ -351,3 +431,423 @@ function StatusMark({ ); } } + +// ─── the graph (expanded view) ──────────────────────────────────────────────── +// +// A hand-built mermaid-style diagram — NOT mermaid's renderer: the styling +// stays in this app's design language (phase colors, quiet strokes) and the +// status coloring maps 1:1 onto the list's marks. Boxes top-down (vertical +// space is the cheap axis here), layered by topoLayout depth, authoring +// order within a layer, edges as vertical bezier curves. + +/** Graph geometry (px): node boxes and the gaps that carry the arrows. */ +export const NODE_W = 176; +export const NODE_H = 48; +export const GAP_X = 28; +export const GAP_Y = 64; + +/** Top-left position per node + the diagram's extent. */ +export interface GraphLayout { + x: Map; + y: Map; + width: number; + height: number; +} + +/** + * Layered positions: depth = row (top-down), authoring order within the + * row, each row centered on the widest one. No crossing-reduction pass — + * the teacher authors nodes in dependency order as a rule, and v1 keeps + * the layout deterministic and readable over prettier. + */ +export function graphLayout(order: readonly PlanNode[], depth: ReadonlyMap): GraphLayout { + const layers: PlanNode[][] = []; + for (const n of order) { + const d = depth.get(n.id) ?? 0; + if (!layers[d]) layers[d] = []; + layers[d].push(n); + } + const rowWidth = (l: PlanNode[]) => l.length * NODE_W + (l.length - 1) * GAP_X; + let maxWidth = 0; + for (const l of layers) maxWidth = Math.max(maxWidth, rowWidth(l)); + const x = new Map(); + const y = new Map(); + layers.forEach((l, d) => { + const start = (maxWidth - rowWidth(l)) / 2; + l.forEach((n, i) => { + x.set(n.id, start + i * (NODE_W + GAP_X)); + y.set(n.id, d * (NODE_H + GAP_Y)); + }); + }); + const height = layers.length * NODE_H + (layers.length - 1) * GAP_Y; + return { x, y, width: maxWidth, height: Math.max(height, 0) }; +} + +/** + * Wrap a node title to at most `maxLines` lines of at most `maxChars` + * (greedy word wrap); overflow ends in an ellipsis, a single oversized + * word is hard-clamped. Pure — unit-tested. + */ +export function wrapTitle(title: string, maxChars = 22, maxLines = 2): string[] { + const words = title.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return [""]; + const lines: string[] = []; + for (const w of words) { + const last = lines[lines.length - 1]; + const fits = last !== undefined && last.length + 1 + w.length <= maxChars; + if (fits) { + lines[lines.length - 1] = `${last} ${w}`; + } else if (lines.length < maxLines) { + lines.push(w.length > maxChars ? `${w.slice(0, maxChars - 1)}…` : w); + } else { + // out of lines: mark the last one as truncated (once) + const lastLine = lines[lines.length - 1]; + if (lastLine !== undefined && !lastLine.endsWith("…")) { + lines[lines.length - 1] = lastLine.length >= maxChars + ? `${lastLine.slice(0, maxChars - 1)}…` + : `${lastLine}…`; + } + } + } + return lines; +} + +/** Box + label styling per status — the graph's face of the list's marks: + * done = colored in (the teach-phase green), current = the position, + * next = the unlocked ring, upcoming = quiet. */ +const GRAPH_BOX: Record = { + done: "fill-phase-teach/10 stroke-phase-teach/50", + current: "fill-primary/10 stroke-primary", + upcoming: "fill-card stroke-border", +}; +const GRAPH_TEXT: Record = { + done: "fill-muted-foreground text-xs", + current: "fill-foreground text-xs font-medium", + upcoming: "fill-muted-foreground/80 text-xs", +}; + +const GRAPH_HEADER_BTN = + "flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"; +const GRAPH_ZOOM_BTN = + "pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"; + +/** Zoom bounds: never below fit-to-panel (the graph can't disappear into + * nothing — fully zoomed out IS the fit), never above 2×. */ +const ZOOM_MAX = 2; +const ZOOM_FLOOR = 0.15; + +/** The graph's view transform: zoom + translation (viewport px). */ +export interface GraphView { + zoom: number; + x: number; + y: number; +} + +/** The fit-centered view: the whole bw×bh graph centered in the w×h + * viewport — fully zoomed out is EXACTLY this, nothing to pan. */ +export function fitGraphView( + box: { w: number; h: number }, + viewport: { w: number; h: number }, + zoom: number, +): GraphView { + return { zoom, x: (viewport.w - box.w * zoom) / 2, y: (viewport.h - box.h * zoom) / 2 }; +} + +/** + * One zoom step toward `focus` (viewport px), clamped to [floor, ZOOM_MAX]. + * At the floor the view SNAPS to the fit-centered position: fully zoomed + * out is THE fit view — centered, pan locked (the component enforces it). + */ +export function zoomedGraphView( + view: GraphView, + factor: number, + floor: number, + focus: { x: number; y: number }, + box: { w: number; h: number }, + viewport: { w: number; h: number }, +): GraphView { + const zoom = Math.min(Math.max(view.zoom * factor, floor), ZOOM_MAX); + if (zoom === view.zoom) return view; + if (zoom <= floor) return fitGraphView(box, viewport, floor); + const k = zoom / view.zoom; + return { zoom, x: focus.x - (focus.x - view.x) * k, y: focus.y - (focus.y - view.y) * k }; +} + +/** + * The interactive diagram: drag to pan, wheel to zoom (to the cursor), the + * fit button resets. The zoom floor is fit-to-panel — at it, the view is + * EXACTLY the centered fit and panning is locked (there is nothing to + * move). Auto-fits on first measure and on layout changes while the view + * sits at the floor; once the learner zooms in or pans, the view is theirs + * (until they return to the floor or press fit). SSR-safe: all measurement + * lives in effects. The zoom controls sit INSIDE the pan surface but + * never start a drag (pointer capture would swallow their clicks). + */ +export function TreeGraph({ tree }: { tree: PlanTree }) { + const { order, depth } = useMemo(() => topoLayout(tree), [tree]); + const nextId = useMemo(() => nextNodeId(tree, order), [tree, order]); + const layout = useMemo(() => graphLayout(order, depth), [order, depth]); + + const viewportRef = useRef(null); + const [size, setSize] = useState({ w: 0, h: 0 }); + + useEffect(() => { + const el = viewportRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(() => { + setSize({ w: el.clientWidth, h: el.clientHeight }); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // The zoom floor: fully zoomed out, the whole diagram fits the panel. + // Pad 16: the fit view IS the default — slim margins fit more graph. + const fitZoom = useMemo(() => { + const pad = 16; + if (!size.w || !size.h || layout.width === 0) return 1; + return Math.max( + ZOOM_FLOOR, + Math.min((size.w - pad) / layout.width, (size.h - pad) / layout.height, 1), + ); + }, [size, layout]); + + const [view, setView] = useState({ x: 0, y: 0, zoom: 1 }); + const userAdjustedRef = useRef(false); + const box = useMemo(() => ({ w: layout.width, h: layout.height }), [layout]); + // Epsilon: zoom is float; "at the floor" needs a tolerance. + const atFloor = view.zoom <= fitZoom + 1e-9; + + // Being AT the floor re-arms auto-fit: fully zoomed out is the default + // view, so later layout changes (tree edits) keep it fitting. + useEffect(() => { + if (atFloor) userAdjustedRef.current = false; + }, [atFloor]); + + // Auto-fit (first measure, layout changes) — suppressed once the learner + // takes manual control, until they return to the floor (or press fit). + useEffect(() => { + if (userAdjustedRef.current || !size.w || !size.h) return; + setView(fitGraphView(box, size, fitZoom)); + }, [fitZoom, size, box]); + + // Wheel zoom to the cursor. Non-passive so the page never scrolls instead. + useEffect(() => { + const el = viewportRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; // Firefox line mode + const factor = Math.exp(-dy * 0.0015); + const rect = el.getBoundingClientRect(); + setView((v) => + zoomedGraphView( + v, + factor, + fitZoom, + { x: e.clientX - rect.left, y: e.clientY - rect.top }, + box, + size, + ), + ); + userAdjustedRef.current = true; + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, [fitZoom, box, size]); + + // Drag pan. Guards: the zoom controls sit inside the pan surface — their + // pointerdown must NOT capture (capture redirects the click away from + // the button — the bug that made them feel dead). And at the fit floor + // there is nothing to pan: don't even begin (the cursor stays honest). + const dragRef = useRef<{ + px: number; + py: number; + x: number; + y: number; + moved: boolean; + } | null>(null); + const startDrag = (e: React.PointerEvent) => { + if (e.button !== 0) return; + if ((e.target as HTMLElement).closest("button")) return; + if (atFloor) return; + dragRef.current = { px: e.clientX, py: e.clientY, x: view.x, y: view.y, moved: false }; + e.currentTarget.classList.add("cursor-grabbing"); + e.currentTarget.setPointerCapture(e.pointerId); + }; + const moveDrag = (e: React.PointerEvent) => { + const d = dragRef.current; + if (!d) return; + d.moved = true; + setView((v) => + v.zoom <= fitZoom + 1e-9 + ? v + : { ...v, x: d.x + (e.clientX - d.px), y: d.y + (e.clientY - d.py) }, + ); + }; + const endDrag = (e: React.PointerEvent) => { + const d = dragRef.current; + if (d) { + dragRef.current = null; + if (d.moved) userAdjustedRef.current = true; + } + e.currentTarget.classList.remove("cursor-grabbing"); + }; + + // Button zoom steps toward the viewport center (same math as wheel). + const stepZoom = (factor: number) => { + userAdjustedRef.current = true; + setView((v) => + zoomedGraphView(v, factor, fitZoom, { x: size.w / 2, y: size.h / 2 }, box, size), + ); + }; + const fitView = () => { + userAdjustedRef.current = false; + if (!size.w || !size.h) return; + setView(fitGraphView(box, size, fitZoom)); + }; + + return ( +
+
+ + + + + + + {tree.edges.map((e) => { + const x1 = (layout.x.get(e.from) ?? 0) + NODE_W / 2; + const y1 = (layout.y.get(e.from) ?? 0) + NODE_H; + const x2 = (layout.x.get(e.to) ?? 0) + NODE_W / 2; + const y2 = layout.y.get(e.to) ?? 0; + const mid = (y1 + y2) / 2; + return ( + ${e.to}`} + d={`M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`} + className="fill-none stroke-muted-foreground/40" + strokeWidth={1.5} + markerEnd="url(#lh-plan-arrow)" + /> + ); + })} + {order.map((n) => { + const status: PlanNodeStatus = planNodeStatus(tree, n.id); + const lines = wrapTitle(n.title); + return ( + + {n.summary ? `${n.title} — ${n.summary}` : n.title} + + {status === "current" && ( + + )} + {lines.map((line, i) => ( + + {line} + + ))} + + ); + })} + +
+ + {/* Quiet affordances: the hint and the zoom controls. */} +
+ Drag to pan · scroll to zoom +
+ + + +
+
+
+ ); +} + +/** The expanded panel (graph mode): wider, the diagram, and the mode toggles. */ +export function TreeGraphPanel({ + tree, + onShowList, + onCollapse, +}: { + tree: PlanTree; + onShowList: () => void; + onCollapse: () => void; +}) { + const doneCount = tree.doneNodeIds.length; + const totalCount = tree.nodes.length; + return ( +
+
+ + Plan + + {doneCount}/{totalCount} + + + +
+ +
+ ); +} diff --git a/src/core/contracts.ts b/src/core/contracts.ts index 4ed31c6..6706f79 100644 --- a/src/core/contracts.ts +++ b/src/core/contracts.ts @@ -5,6 +5,7 @@ */ import type { UIMessage } from "ai"; +import type { Phase3 } from "./phases.ts"; // ─── Plan tree (vision §2 "formal plan tree") ──────────────────────────────── // A session-scoped dependency tree authored by the teacher via plan tools. @@ -257,13 +258,35 @@ export interface ChatRecord { tree?: PlanTree; } -/** The list view's shape (messageCount instead of full messages). */ +/** The list view's shape (messageCount instead of full messages) + the + * per-session status the picker renders. `phase` and `pendingQuestion` + * derive from the stored record (tree + messages); the store computes + * them in listChats. Liveness (`busy`) is NOT record-derived — the API + * route merges it in (see ChatListItem). + * + * Phase semantics (three phases, data-only): probe = no plan yet, plan = + * tree exists but no node occupied, teach = a node is occupied + * (core/phases.ts phaseOfTree). + */ export interface ChatSummary { id: string; title: string; createdAt: string; updatedAt: string; messageCount: number; + /** The session's current phase, derived from the stored plan tree. */ + phase: Phase3; + /** An unanswered quiz/ask question is waiting for the learner. */ + pendingQuestion: boolean; +} + +/** The sidebar's session row: a summary + live liveness. The chats list + * route attaches `busy` from the turn-liveness registry and the job store + * (a running turn OR a background job = the session is working). The store + * itself can't see liveness (import law: store never imports server), + * hence the split at exactly the record/liveness seam. */ +export interface ChatListItem extends ChatSummary { + busy: boolean; } // ─── Async sub-agent jobs (vision §2.5; plan.md Wave 0 protocol) ──────────── diff --git a/src/core/phases.ts b/src/core/phases.ts index 4312829..3157184 100644 --- a/src/core/phases.ts +++ b/src/core/phases.ts @@ -45,3 +45,110 @@ export function phaseAt(messages: readonly PhaseMessage[], upToIndex?: number): } return "probe"; } + +// ─── The three-phase view (probe / plan / teach) ────────────────────────────── +// +// The session picker's color strips, the header chip, and the transcript +// dividers all speak THREE phases, derived data-only from the plan tree: +// +// probe — no plan output yet (the tree is empty or absent); +// plan — a tree exists but no node is occupied (plan_set_current not fired); +// teach — a node is occupied. +// +// The teacher's invisible PRE-plan planning (reasoning before plan_init +// fires) reads as probe until the plan lands — an accepted limitation: no +// observable signal marks "I'm now planning" (a teacher-side marker would be +// an agent change; see phases.md discussion in the transcript divider work). + +/** The three phases of a session, as the picker / header / dividers see it. */ +export type Phase3 = "probe" | "plan" | "teach"; + +/** The advance-only order of phases: a session never visibly regresses. */ +const PHASE3_RANK: Record = { probe: 0, plan: 1, teach: 2 }; + +/** Structural slice of a plan tree — anything with nodes + a current node. */ +export interface PhaseTree { + nodes: ReadonlyArray; + currentNodeId: string | null; +} + +/** + * The phase of a tree, point-in-time: no nodes → probe, no current node → + * plan, an occupied node → teach. Used for the chats summary (the stored + * tree IS the session state there). Empty trees (emptyTree(), a failed + * plan_init) read as probe — a seam only counts once a plan exists. + */ +export function phaseOfTree(tree: PhaseTree | null | undefined): Phase3 { + if (!tree || tree.nodes.length === 0) return "probe"; + return tree.currentNodeId === null ? "plan" : "teach"; +} + +/** + * Minimal structural slice of a UI message for the three-phase scan: parts + * with a state and (when a plan op completed) its output. + */ +export interface Phase3Message { + parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }>; +} + +/** A successful plan-op output as it appears on the part (`PlanToolResult`). */ +interface PlanOutput { + tree?: { nodes?: unknown }; +} + +/** + * The three-phase boundary semantics over the TRANSCRIPT (the same data the + * tree panel reads): scanning forward from 0 to `upToIndex` (default: the + * last message), the phase advances probe → plan at the first plan part + * with a NON-EMPTY tree available (a failed plan_init carries an empty tree + * and never marks the seam), and plan → teach at the first tree output with + * an occupied node. MONOTONIC by design: the phase only ever advances — a + * node removal mid-teaching (currentNodeId → null) is an edit, not a phase + * regression, so no "Plan" divider ever appears below teaching content. + * + * `phaseAt` (two-phase) stays as-is: the plan_init call is its seam and the + * question dock's reveal logic leans on it. The two views differ only in + * the window between plan_init and the first plan_set_current, where + * three-phase reads "plan". + */ +export function phaseAt3(messages: readonly Phase3Message[], upToIndex?: number): Phase3 { + let phase: Phase3 = "probe"; + const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1); + for (let i = 0; i <= last; i++) { + const parts = messages[i]?.parts; + if (!parts) continue; + for (const part of parts) { + if (!part.type.startsWith("tool-plan_") || part.state !== "output-available") continue; + const tree = (part.output as PlanOutput | undefined)?.tree as PhaseTree | undefined; + if (!tree || !Array.isArray(tree.nodes)) continue; + const candidate = phaseOfTree(tree); + if (PHASE3_RANK[candidate] > PHASE3_RANK[phase]) phase = candidate; + } + } + return phase; +} + +/** + * The phase at EVERY message index, in one forward fold — the transcript + * dividers render one per boundary, so phaseAt3(m, i) per index would + * rescan 0..i each time (quadratic); this is the same derivation, linear. + * out[i] === phaseAt3(messages, i) for every index. + */ +export function phaseTimeline(messages: readonly Phase3Message[]): Phase3[] { + const out: Phase3[] = []; + let phase: Phase3 = "probe"; + for (let i = 0; i < messages.length; i++) { + const parts = messages[i]?.parts; + if (parts) { + for (const part of parts) { + if (!part.type.startsWith("tool-plan_") || part.state !== "output-available") continue; + const tree = (part.output as PlanOutput | undefined)?.tree as PhaseTree | undefined; + if (!tree || !Array.isArray(tree.nodes)) continue; + const candidate = phaseOfTree(tree); + if (PHASE3_RANK[candidate] > PHASE3_RANK[phase]) phase = candidate; + } + } + out.push(phase); + } + return out; +} diff --git a/src/store/chats.ts b/src/store/chats.ts index 007a5da..ca56ec4 100644 --- a/src/store/chats.ts +++ b/src/store/chats.ts @@ -13,6 +13,8 @@ import { PATHS } from "../../config.ts"; import type { ChatStore } from "../core/ports.ts"; import type { ChatRecord, ChatSummary, PlanTree } from "../core/contracts.ts"; import type { UIMessage } from "ai"; +import { findPendingToolCall } from "../core/pending.ts"; +import { phaseOfTree } from "../core/phases.ts"; function chatFile(id: string): string { if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`Invalid chat id: ${id}`); @@ -49,7 +51,16 @@ function listChats(): ChatSummary[] { .filter((f) => f.endsWith(".json")) .map((f) => getChat(f.slice(0, -5))) .filter((r): r is ChatRecord => r !== null) - .map(({ messages, ...summary }) => ({ ...summary, messageCount: messages.length })) + .map(({ messages, tree, ...summary }) => ({ + ...summary, + messageCount: messages.length, + // Record-derived status for the session picker: phase from the + // stored plan tree, pending question from the transcript's last + // unanswered quiz/ask part. Liveness (busy) is NOT record-derived — + // the API route merges it (contracts.ts ChatListItem). + phase: phaseOfTree(tree), + pendingQuestion: findPendingToolCall(messages) !== null, + })) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); } catch { return []; diff --git a/tests/phases.test.ts b/tests/phases.test.ts index 0ce8005..45728ab 100644 --- a/tests/phases.test.ts +++ b/tests/phases.test.ts @@ -8,7 +8,14 @@ */ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { phaseAt, type PhaseMessage } from "../src/core/phases.ts"; +import { + phaseAt, + phaseAt3, + phaseOfTree, + phaseTimeline, + type Phase3Message, + type PhaseMessage, +} from "../src/core/phases.ts"; /** A minimal message: just typed parts, exactly the structural slice phaseAt reads. */ function msg(...partTypes: string[]): PhaseMessage { @@ -83,3 +90,131 @@ describe("phaseAt", () => { assert.equal(phaseAt([]), "probe"); }); }); + +// ─── The three-phase view (picker strips, header chip, transcript dividers) ──── + +/** A minimal tree: nodes as opaque ids is all phaseOfTree reads. */ +function tree(nodes: string[], currentNodeId: string | null): { nodes: unknown[]; currentNodeId: string | null } { + return { nodes: nodes.map((id) => ({ id })), currentNodeId }; +} + +/** A message whose parts are full plan-op tool parts with outputs. */ +function planMsg( + ...ops: { tool: string; ok: boolean; tree: ReturnType }[] +): Phase3Message { + return { + parts: ops.map((op, i) => ({ + type: `tool-plan_${op.tool}`, + state: "output-available", + toolCallId: `p${i}`, + output: { ok: op.ok, tree: op.tree }, + })), + }; +} + +describe("phaseOfTree", () => { + it("no tree / empty tree → probe", () => { + assert.equal(phaseOfTree(undefined), "probe"); + assert.equal(phaseOfTree(null), "probe"); + assert.equal(phaseOfTree(tree([], null)), "probe", "emptyTree() (a failed init) reads probe"); + }); + + it("tree with no current node → plan", () => { + assert.equal(phaseOfTree(tree(["a", "b"], null)), "plan"); + }); + + it("occupied node → teach", () => { + assert.equal(phaseOfTree(tree(["a", "b"], "a")), "teach"); + }); +}); + +describe("phaseAt3", () => { + it("no plan parts anywhere → probe at every index", () => { + const messages: Phase3Message[] = [msg("text"), msg("tool-quiz"), msg("text")]; + for (let i = 0; i < messages.length; i++) { + assert.equal(phaseAt3(messages, i), "probe", `index ${i}`); + } + assert.equal(phaseAt3([]), "probe"); + }); + + it("the probe→plan seam: the first plan output with a non-empty tree", () => { + const messages: Phase3Message[] = [ + msg("text"), // probe chatter + planMsg({ tool: "plan_init", ok: false, tree: tree([], null) }), // failed init: NOT a seam + msg("text"), + planMsg({ tool: "plan_init", ok: true, tree: tree(["a", "b"], null) }), // ← the seam + msg("text"), + ]; + assert.equal(phaseAt3(messages, 0), "probe"); + assert.equal(phaseAt3(messages, 1), "probe", "a failed init carries an empty tree — no seam"); + assert.equal(phaseAt3(messages, 2), "probe"); + assert.equal(phaseAt3(messages, 3), "plan"); + assert.equal(phaseAt3(messages, 4), "plan"); + assert.equal(phaseAt3(messages), "plan", "default = current phase"); + }); + + it("the plan→teach seam: the first tree output with an occupied node", () => { + const messages: Phase3Message[] = [ + msg("text"), + planMsg({ tool: "plan_init", ok: true, tree: tree(["a", "b"], null) }), + msg("text"), + planMsg({ tool: "plan_set_current", ok: true, tree: tree(["a", "b"], "a") }), // ← the seam + msg("text"), + ]; + assert.equal(phaseAt3(messages, 2), "plan"); + assert.equal(phaseAt3(messages, 3), "teach"); + assert.equal(phaseAt3(messages, 4), "teach"); + }); + + it("collapsed seams: init and set_current in one message → straight to teach", () => { + const messages: Phase3Message[] = [ + msg("text"), + planMsg( + { tool: "plan_init", ok: true, tree: tree(["a"], null) }, + { tool: "plan_set_current", ok: true, tree: tree(["a"], "a") }, + ), + ]; + assert.equal(phaseAt3(messages, 0), "probe"); + assert.equal(phaseAt3(messages, 1), "teach"); + }); + + it("monotonic: a mid-teach node removal (current → null) never regresses the phase", () => { + const messages: Phase3Message[] = [ + planMsg({ tool: "plan_init", ok: true, tree: tree(["a", "b"], null) }), + planMsg({ tool: "plan_set_current", ok: true, tree: tree(["a", "b"], "a") }), + msg("text"), + planMsg({ tool: "plan_remove_node", ok: true, tree: tree(["b"], null) }), // edit, not a regression + ]; + assert.equal(phaseAt3(messages, 2), "teach"); + assert.equal(phaseAt3(messages, 3), "teach", "phase only ever advances"); + }); + + it("upToIndex past the end / negative → clamped, never throws", () => { + const messages: Phase3Message[] = [planMsg({ tool: "plan_init", ok: true, tree: tree(["a"], null) })]; + assert.equal(phaseAt3(messages, 99), "plan"); + assert.equal(phaseAt3(messages, -1), "probe", "empty scan range → probe"); + }); +}); + +describe("phaseTimeline", () => { + it("equals phaseAt3 at every index (the linear fold is the same derivation)", () => { + const messages: Phase3Message[] = [ + msg("text"), + planMsg({ tool: "plan_init", ok: false, tree: tree([], null) }), + planMsg({ tool: "plan_init", ok: true, tree: tree(["a", "b"], null) }), + msg("text"), + planMsg({ tool: "plan_set_current", ok: true, tree: tree(["a", "b"], "a") }), + msg("text"), + ]; + const timeline = phaseTimeline(messages); + assert.equal(timeline.length, messages.length); + for (let i = 0; i < messages.length; i++) { + assert.equal(timeline[i], phaseAt3(messages, i), `index ${i}`); + } + assert.deepEqual(timeline, ["probe", "probe", "plan", "plan", "teach", "teach"]); + }); + + it("empty transcript → empty timeline", () => { + assert.deepEqual(phaseTimeline([]), []); + }); +}); diff --git a/tests/render-cards.test.tsx b/tests/render-cards.test.tsx index def9193..f1c00d8 100644 --- a/tests/render-cards.test.tsx +++ b/tests/render-cards.test.tsx @@ -18,7 +18,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { MessageResponse } from "../components/ai-elements/message.tsx"; import { QuestionDock } from "../components/question-dock.tsx"; import { AnsweredCard } from "../components/answered-card.tsx"; -import { PlanDivider } from "../components/artifacts/plan-divider.tsx"; import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts"; const quizInput = { @@ -42,7 +41,6 @@ function quizQD(): QuestionData { }; } - function renderDock(props: { toolName: "quiz" | "ask_user_question"; toolCallId: string; input: never; onSubmit?: () => void }) { // expanded=true renders the full form (the collapsed bar is covered by the // QuestionDockBar test below). No scroll context needed — the DockOverlay @@ -220,44 +218,3 @@ describe("teach-check reveal (W5 phase separation)", () => { }); }); -describe("PlanDivider (the probe→teach seam)", () => { - const tree = { - nodes: [ - { id: "a", title: "A" }, - { id: "b", title: "B" }, - { id: "c", title: "C" }, - ], - edges: [{ from: "a", to: "b" }], - currentNodeId: null, - doneNodeIds: [], - version: 1, - }; - - it("renders the quiet divider with the node count once output-available", () => { - const html = renderToStaticMarkup( - , - ); - assert.ok(html.includes('data-testid="plan-divider"')); - assert.match(html, /Teaching/); - assert.match(html, /3 nodes/, "node count from the output tree"); - assert.ok(!html.includes("banner"), "never a loud banner"); - }); - - it("renders null while the tool streams and on a failed init", () => { - const streaming = renderToStaticMarkup( - , - ); - assert.equal(streaming, "", "null while input-streaming"); - const failed = renderToStaticMarkup( - , - ); - assert.equal(failed, "", "a refused re-init must not draw a second seam"); - }); -}); diff --git a/tests/render-phase-divider.test.tsx b/tests/render-phase-divider.test.tsx new file mode 100644 index 0000000..2054625 --- /dev/null +++ b/tests/render-phase-divider.test.tsx @@ -0,0 +1,95 @@ +/** + * Phase presentation render tests — the in-session face of the picker's + * color strips (components/phase-divider.tsx): the header chip and the + * dotted transcript boundary rules. Same tsx classic-JSX shim as + * render-cards.test.tsx. (Replaces the old part-level PlanDivider tests.) + */ +import React from "react"; +(globalThis as Record).React = React; + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { PhaseChip, PhaseDivider, planNodeCount } from "../components/phase-divider.tsx"; + +describe("PhaseChip (header)", () => { + it("renders a phase-colored dot + the learner-facing label", () => { + for (const [phase, label] of [ + ["probe", "Probing"], + ["plan", "Planning"], + ["teach", "Teaching"], + ] as const) { + const html = renderToStaticMarkup(); + assert.ok(html.includes('data-testid="phase-chip"')); + assert.ok(html.includes(`data-phase="${phase}"`)); + assert.ok(html.includes(label), `${phase} label`); + assert.ok(html.includes(`bg-phase-${phase}`), `${phase} dot color`); + } + }); +}); + +describe("PhaseDivider (transcript boundary)", () => { + it("the plan seam: dotted rules in the plan color + node count", () => { + const html = renderToStaticMarkup(); + assert.ok(html.includes('data-testid="phase-divider-plan"')); + assert.match(html, /Plan · 3 nodes/); + assert.ok(html.includes("border-dotted"), "dotted rule"); + assert.ok(html.includes("border-phase-plan"), "plan-colored rule"); + }); + + it("the teach seam: dotted rules in the teach color, no count", () => { + const html = renderToStaticMarkup(); + assert.ok(html.includes('data-testid="phase-divider-teach"')); + assert.match(html, /Teaching/); + assert.ok(html.includes("border-phase-teach")); + assert.ok(!html.includes("nodes")); + }); + + it("singular node count", () => { + const html = renderToStaticMarkup(); + assert.match(html, /Plan · 1 node\b/); + }); + + it("a plan seam without a count still labels itself", () => { + const html = renderToStaticMarkup(); + assert.match(html, /Planning/); + }); + + it("probe renders null (never a boundary — phases only advance)", () => { + assert.equal(renderToStaticMarkup(), ""); + }); +}); + +describe("planNodeCount (the seam message's tree)", () => { + it("counts the first output-available plan tree in the message", () => { + const count = planNodeCount({ + parts: [ + { type: "text", text: "Here's your plan." }, + { + type: "tool-plan_init", + state: "output-available", + toolCallId: "p1", + output: { ok: true, tree: { nodes: [{ id: "a" }, { id: "b" }], edges: [], currentNodeId: null, doneNodeIds: [], version: 1 } }, + }, + ], + }); + assert.equal(count, 2); + }); + + it("skips streaming/failed parts (an empty or failed tree gives no count)", () => { + assert.equal( + planNodeCount({ parts: [{ type: "tool-plan_init", state: "input-streaming" }] }), + undefined, + ); + assert.equal( + planNodeCount({ + parts: [ + { type: "tool-plan_init", state: "output-available", toolCallId: "p1", output: { ok: false, tree: { nodes: [], edges: [], currentNodeId: null, doneNodeIds: [], version: 0 } } }, + ], + }), + undefined, + "an empty tree (failed init) carries no count", + ); + assert.equal(planNodeCount({ parts: [{ type: "text" }] }), undefined); + }); +}); diff --git a/tests/render-sidebar.test.tsx b/tests/render-sidebar.test.tsx new file mode 100644 index 0000000..ee9f56f --- /dev/null +++ b/tests/render-sidebar.test.tsx @@ -0,0 +1,109 @@ +/** + * Session-row render tests — the picker's status language. One dot, two + * channels: COLOR = phase (sky/violet/emerald), FILL+MOTION = what's owed + * (pulsing = teacher working, solid = answer owed, hollow = idle). Pure + * function of the list item, so SSR of pins every visible + * state. Same tsx classic-JSX shim as render-cards.test.tsx. + */ +import React from "react"; +(globalThis as Record).React = React; + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { SessionRow, timeAgo } from "../components/sidebar.tsx"; +import type { ChatListItem, Phase3 } from "../src/core/index.ts"; + +function item(overrides: Partial): ChatListItem { + return { + id: "c1", + title: "Test session", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + messageCount: 4, + phase: "probe", + pendingQuestion: false, + busy: false, + ...overrides, + }; +} + +function render(chat: ChatListItem, active = false) { + return renderToStaticMarkup( {}} />); +} + +describe("SessionRow", () => { + it("no phase strips — the dot alone carries the phase color", () => { + const html = render(item({ phase: "teach" })); + assert.ok(!html.includes("phase-strip"), "strip removed by design (too busy)"); + assert.ok(!html.includes("absolute top-2"), "no leftover strip geometry"); + }); + + it("idle session: hollow phase-colored ring, perfectly still", () => { + for (const phase of ["probe", "plan", "teach"] as Phase3[]) { + const html = render(item({ phase })); + assert.ok(html.includes("session-dot-idle"), `${phase} idle dot present`); + assert.ok(html.includes(`border-phase-${phase}`), `${phase} ring carries its phase color`); + assert.ok(!html.includes("lh-dot-breathe"), "idle is never animated"); + assert.ok(!html.includes("bg-phase-"), "hollow: no fill"); + } + }); + + it("a pending question renders the SOLID phase dot — no animation (the dominant state stays calm)", () => { + const html = render(item({ phase: "teach", pendingQuestion: true })); + assert.ok(html.includes("session-dot-awaiting"), "awaiting dot present"); + assert.ok(html.includes("bg-phase-teach"), "solid fill in the phase color"); + assert.ok(!html.includes("lh-dot-breathe"), "motion is reserved for working"); + assert.ok(!html.includes("bg-attention"), "amber channel is gone"); + assert.ok(html.includes("Waiting for your answer"), "label"); + }); + + it("a working session renders the pulsing phase dot", () => { + const html = render(item({ phase: "plan", busy: true })); + assert.ok(html.includes("session-dot-working"), "working dot present"); + assert.ok(html.includes("lh-dot-breathe"), "breathing animation"); + assert.ok(html.includes("bg-phase-plan"), "the pulse is phase-colored"); + assert.ok(html.includes("Teacher is working"), "label"); + }); + + it("a pending answer outranks 'working' when both are true", () => { + const html = render(item({ pendingQuestion: true, busy: true })); + assert.ok(html.includes("session-dot-awaiting")); + assert.ok(!html.includes("session-dot-working"), "one dot at a time — awaiting wins"); + }); + + it("the meta line: state replaces the count when the state is the useful fact", () => { + const awaiting = render(item({ pendingQuestion: true, updatedAt: "2026-01-02T01:00:00.000Z" })); + assert.ok(awaiting.includes("Waiting for your answer · "), "awaiting meta"); + assert.ok(!awaiting.includes("messages"), "count yields to the state"); + assert.ok(!awaiting.includes("lh-dot-breathe"), "…and the dot is still solid"); + + const working = render(item({ busy: true })); + assert.ok(working.includes("Teacher is working…"), "working meta"); + + const idle = render(item({ title: "Partial derivatives", messageCount: 7 })); + assert.ok(idle.includes("Partial derivatives")); + assert.match(idle, /7 messages · \d{1,2}\/\d{1,2}\/\d{4}/, "idle keeps the count"); + }); + + it("active row is visually marked", () => { + const html = render(item({}), true); + assert.ok(html.includes("font-medium"), "active emphasis"); + }); +}); + +describe("timeAgo", () => { + const now = new Date("2026-01-02T12:00:00.000Z"); + + it("just now / minutes / hours / days, then falls back to a date", () => { + assert.equal(timeAgo("2026-01-02T11:59:30.000Z", now), "just now"); + assert.equal(timeAgo("2026-01-02T11:55:00.000Z", now), "5m ago"); + assert.equal(timeAgo("2026-01-02T09:00:00.000Z", now), "3h ago"); + assert.equal(timeAgo("2025-12-31T12:00:00.000Z", now), "2d ago"); + assert.match(timeAgo("2025-06-02T12:00:00.000Z", now), /\d/, "beyond a week: a date"); + }); + + it("negative drift (clock skew) reads as just now, never a future date", () => { + assert.equal(timeAgo("2026-01-02T12:01:00.000Z", now), "just now"); + }); +}); diff --git a/tests/render-tree-panel.test.tsx b/tests/render-tree-panel.test.tsx index 616a6d6..9986d65 100644 --- a/tests/render-tree-panel.test.tsx +++ b/tests/render-tree-panel.test.tsx @@ -270,3 +270,192 @@ describe("TreePanel rendering", () => { assert.ok(!html.includes("Foundation"), "no node content in the strip"); }); }); + +// ─── the graph (expanded view) ──────────────────────────────────────────────── + +import { + TreeGraph, + graphLayout, + wrapTitle, + NODE_H, + NODE_W, +} from "../components/tree-panel.tsx"; + +describe("wrapTitle (box labels)", () => { + it("wraps to lines within the char budget", () => { + assert.deepEqual(wrapTitle("Chain rule intuition for composites"), [ + "Chain rule intuition", + "for composites", + ]); + }); + + it("a title that fits stays one line", () => { + assert.deepEqual(wrapTitle("Foundation"), ["Foundation"]); + }); + + it("overflowing titles end in an ellipsis", () => { + const lines = wrapTitle("Substitution and the differential of implicit functions"); + assert.equal(lines.length, 2); + assert.ok(lines.at(-1)?.endsWith("…")); + }); + + it("a single oversized word is hard-clamped", () => { + const lines = wrapTitle("antidifferentiation-techniques-overview"); + assert.ok(lines.every((l) => l.length <= 22)); + assert.ok(lines.at(-1)?.endsWith("…")); + }); + + it("empty titles render one empty line (never an empty array)", () => { + assert.deepEqual(wrapTitle(""), [""]); + }); +}); + +describe("graphLayout (layered, top-down)", () => { + it("one node per depth row, authoring order within the row, rows centered", () => { + const { order, depth } = topoLayout(chainTree); + const layout = graphLayout(order, depth); + assert.equal(layout.height, NODE_H * 3 + 64 * 2, "three rows plus arrow gaps"); + // b, a: depth 0; c: depth 1 → wait: a is authored second but is the root. + assert.equal(depth.get("a"), 0); + assert.equal(depth.get("b"), 1); + assert.equal(depth.get("c"), 2); + // each row of one node is centered on the (single-column) width. + assert.equal(layout.x.get("a"), 0); + assert.equal(layout.x.get("b"), 0); + assert.equal(layout.x.get("c"), 0); + assert.equal(layout.y.get("a"), 0); + assert.equal(layout.y.get("b"), NODE_H + 64); + }); + + it("two same-depth nodes sit side by side, centered over the widest row", () => { + const tree: PlanTree = { + nodes: [ + { id: "r", title: "Root" }, + { id: "x", title: "X" }, + { id: "y", title: "Y" }, + ], + edges: [ + { from: "r", to: "x" }, + { from: "r", to: "y" }, + ], + currentNodeId: null, + doneNodeIds: [], + version: 1, + }; + const { order, depth } = topoLayout(tree); + const layout = graphLayout(order, depth); + const xX = layout.x.get("x")!; + const xY = layout.x.get("y")!; + assert.equal(xY - xX, NODE_W + 28, "one box + one gap apart"); + // row 1 is the widest → row 0 centers over it: the root sits exactly + // between the two dependents' centers (88 and 292 → 190 - 88 = 102). + assert.equal(layout.x.get("r"), (2 * NODE_W + 28 - NODE_W) / 2, "the single root centers over the two-wide row"); + assert.equal(layout.width, 2 * NODE_W + 28); + }); +}); + +describe("TreeGraph rendering", () => { + it("renders one box per node with its status, one arrow per edge", () => { + const html = renderToStaticMarkup(); + for (const [id, status] of [ + ["a", "done"], + ["b", "current"], + ["c", "upcoming"], + ] as const) { + assert.ok( + new RegExp(`data-testid="graph-node-${id}"[^>]*data-status="${status}"`).test(html), + `${id} is ${status}`, + ); + } + assert.equal(html.match(/marker-end="url\(#lh-plan-arrow\)"/g)?.length, 2, "two edges, two arrows"); + }); + + it("colors progress: done boxes carry the teach-phase tint", () => { + const html = renderToStaticMarkup(); + assert.match(html, /data-status="done"[\s\S]*?fill-phase-teach\/10/); + assert.match(html, /data-status="current"[\s\S]*?stroke-primary/); + }); + + it("marks the next unlocked node", () => { + const tree: PlanTree = { + nodes: [ + { id: "a", title: "Foundation" }, + { id: "b", title: "Middle" }, + ], + edges: [{ from: "a", to: "b" }], + currentNodeId: null, + doneNodeIds: ["a"], + version: 2, + }; + const html = renderToStaticMarkup(); + assert.match(html, /data-testid="graph-node-b"[^>]*data-next="true"/); + }); + + it("node titles wrap inside the box (two lines max, ellipsis on overflow)", () => { + const tree: PlanTree = { + nodes: [{ id: "long", title: "Substitution and the differential of implicit functions" }], + edges: [], + currentNodeId: null, + doneNodeIds: [], + version: 1, + }; + const html = renderToStaticMarkup(); + const texts = html.match(/]*>([^<]*)<\/text>/g) ?? []; + assert.equal(texts.length, 2, "two wrapped lines"); + assert.ok(texts.at(-1)?.includes("…")); + }); + + it("hover shows title and summary", () => { + const html = renderToStaticMarkup(); + assert.ok(html.includes("Foundation — Where it all starts.")); + }); +}); + +// ─── graph view math (pan lock at fit, zoom-to-cursor) ──────────────────────── + +import { + fitGraphView, + zoomedGraphView, + type GraphView, +} from "../components/tree-panel.tsx"; + +describe("fitGraphView / zoomedGraphView", () => { + const box = { w: 1000, h: 800 }; + const viewport = { w: 300, h: 400 }; + + it("fit centers the whole graph in the viewport at the given zoom", () => { + const v = fitGraphView(box, viewport, 0.3); + assert.equal(v.zoom, 0.3); + assert.equal(v.x, (300 - 1000 * 0.3) / 2); + assert.equal(v.y, (400 - 800 * 0.3) / 2); + }); + + it("zooming in moves the point under the cursor to stay put (zoom-to-cursor)", () => { + const v: GraphView = { zoom: 0.5, x: -100, y: -50 }; + // world point at cursor (50, 60) pre-zoom: (50 - (-100))/0.5 = 300 + const next = zoomedGraphView(v, 2, 0.1, { x: 50, y: 60 }, box, viewport); + assert.equal(next.zoom, 1); + // the same world point maps back to the cursor: (50 - next.x)/1 = 300 + assert.equal(50 - next.x, 300); + assert.equal(60 - next.y, 220); + }); + + it("zooming out past the floor SNAPS to the centered fit (fully zoomed out = the fit)", () => { + const v: GraphView = { zoom: 1.8, x: -900, y: -400 }; + const next = zoomedGraphView(v, 1 / 8, 0.3, { x: 100, y: 100 }, box, viewport); + assert.equal(next.zoom, 0.3, "clamped at the floor"); + assert.deepEqual(next, fitGraphView(box, viewport, 0.3), "and re-centered: nothing to pan"); + }); + + it("a no-op zoom returns the same view (no drift)", () => { + const v: GraphView = { zoom: 2, x: 10, y: 10 }; + // factor 1 → same zoom; at MAX already → clamped to same → same view + assert.equal(zoomedGraphView(v, 1, 0.3, { x: 0, y: 0 }, box, viewport), v); + }); + + it("zoom-in clamps at the ceiling (2×)", () => { + const v: GraphView = { zoom: 1.9, x: 0, y: 0 }; + const next = zoomedGraphView(v, 3, 0.3, { x: 10, y: 10 }, box, viewport); + assert.equal(next.zoom, 2); + }); +});