Merge feat/ui-polish: three-phase session model, plan graph, layout restructure

chat.tsx woven by hand: UI's session-surface layout (transcript column +
right sidebar as full-height siblings, h-10 header with PhaseChip) wrapped
in sub-agents' JobsContext.Provider; the message map carries both the
steer ternary (steers bypass the Message wrapper — the model-facing glue
text never renders) and UI's PhaseDivider emission (steers are verified
phase-boundary no-ops). Right sidebar = sub-agents' fleet panel column
(SubAgentsPanel above TreePanel, xl-only) carried onto UI's skeleton.
contracts.ts auto-merged (ChatSummary.phase/pendingQuestion + ChatListItem,
additive above the async-protocol section).

Smoke-verified on the merged tree: steer renders bare as its artifact
badge with no learner bubble, no rendered glue text, and no spurious
phase divider at the steer boundary.
This commit is contained in:
amosblomqvist
2026-09-02 16:40:47 +03:00
17 changed files with 1576 additions and 183 deletions
+11 -2
View File
@@ -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) {
+52
View File
@@ -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;
}
}
+4 -1
View File
@@ -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 })
<html lang="en" className="dark">
<body className="min-h-dvh antialiased">
<SidebarProvider>
<AppShell>{children}</AppShell>
<TreePanelModeProvider>
<AppShell>{children}</AppShell>
</TreePanelModeProvider>
</SidebarProvider>
</body>
</html>
+3 -3
View File
@@ -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<string, ComponentType<ArtifactPartProps>> = {
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,
-29
View File
@@ -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 (
<div className="my-4 flex w-full items-center gap-3" data-testid="plan-divider">
<span className="h-px flex-1 bg-border/80" />
<span className="rounded-full border border-border/80 px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Teaching{nodeCount > 0 ? ` · ${nodeCount} node${nodeCount === 1 ? "" : "s"}` : ""}
</span>
<span className="h-px flex-1 bg-border/80" />
</div>
);
}
+41 -21
View File
@@ -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 (
<ChatActionsContext.Provider value={{ chatId, syncFromServer, sendText: (t: string) => void sendMessage({ text: t }) }}>
<JobsContext.Provider value={jobs}>
<div className="flex h-dvh flex-col">
<header className="flex items-center gap-3 px-3 py-2">
<SidebarToggle />
<div className="min-w-0 flex-1 truncate font-medium">{title}</div>
</header>
{/* 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. */}
<div className="flex h-dvh">
<div className="flex min-w-0 flex-1 flex-col">
{/* 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. */}
<header className="flex h-10 items-center gap-3 px-3">
<SidebarToggle />
<div className="min-w-0 flex-1 truncate font-medium">{title}</div>
<PhaseChip phase={phases.at(-1) ?? "probe"} />
</header>
{/* 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. */}
<div className="relative flex min-h-0 flex-1">
<div className="relative flex min-h-0 flex-1">
<Conversation className="h-full min-w-0 flex-1" initial="instant">
<ConversationContent className="min-h-full gap-0 p-0">
<div className={`${COLUMN} flex flex-1 flex-col gap-4 pt-4`}>
@@ -296,7 +309,11 @@ export function Chat({
// can still hold empty ids — the index keeps keys unique for them.
<SubagentSteerCard key={m.id || `m-${mi}`} message={m} />
) : (
<Message key={m.id || `m-${mi}`} from={m.role} className={m.role === "assistant" ? "max-w-full" : undefined}>
<Fragment key={m.id || `m-${mi}`}>
{mi > 0 && phases[mi] !== phases[mi - 1] && (
<PhaseDivider phase={phases[mi]!} nodeCount={planNodeCount(m)} />
)}
<Message from={m.role} className={m.role === "assistant" ? "max-w-full" : undefined}>
<MessageContent className={m.role === "assistant" ? "w-full" : undefined}>
{m.role === "user" ? (
// User text renders plain: the model's LaTeX pipeline is not
@@ -342,7 +359,8 @@ export function Chat({
})
)}
</MessageContent>
</Message>
</Message>
</Fragment>
))}
{(status === "submitted" || turnInFlight) && <Shimmer className="text-sm">Thinking…</Shimmer>}
@@ -381,11 +399,13 @@ export function Chat({
</ConversationContent>
<ConversationScrollButton />
</Conversation>
{/* 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. */}
<div className="hidden shrink-0 flex-col xl:flex">
<SubAgentsPanel jobs={jobs} />
<TreePanel messages={messages} />
</div>
</div>
</div>
</div>
</JobsContext.Provider>
+99
View File
@@ -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<Phase3, string> = {
probe: "Probing",
plan: "Planning",
teach: "Teaching",
};
const PHASE_DOT: Record<Phase3, string> = {
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 (
<span
data-testid="phase-chip"
data-phase={phase}
title={`Session phase: ${CHIP_LABEL[phase]}`}
className="flex shrink-0 items-center gap-1.5 rounded-full border border-border/80 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground"
>
<span className={cn("size-1.5 rounded-full", PHASE_DOT[phase])} aria-hidden />
{CHIP_LABEL[phase]}
</span>
);
}
/** Divider rule + chip colors per phase (probe never reaches here). */
const DIVIDER_RULE: Partial<Record<Phase3, string>> = {
plan: "border-phase-plan/50",
teach: "border-phase-teach/50",
};
const DIVIDER_TEXT: Partial<Record<Phase3, string>> = {
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 (
<div className="my-4 flex w-full items-center gap-3" data-testid={`phase-divider-${phase}`}>
<span className={cn("h-0 flex-1 border-t-2 border-dotted", DIVIDER_RULE[phase])} aria-hidden />
<span
className={cn(
"rounded-full border px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-wide",
DIVIDER_TEXT[phase],
)}
>
{label}
</span>
<span className={cn("h-0 flex-1 border-t-2 border-dotted", DIVIDER_RULE[phase])} aria-hidden />
</div>
);
}
/**
* 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;
}
+143 -30
View File
@@ -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<Phase3, string> = {
probe: "bg-phase-probe",
plan: "bg-phase-plan",
teach: "bg-phase-teach",
};
const PHASE_RING: Record<Phase3, string> = {
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 (
<span
data-testid="session-dot-awaiting"
title="Waiting for your answer"
className={cn("size-2 shrink-0 rounded-full", PHASE_FILL[phase])}
>
<span className="sr-only">Waiting for your answer</span>
</span>
);
}
if (busy) {
return (
<span
data-testid="session-dot-working"
title="Teacher is working"
className={cn("size-2 shrink-0 rounded-full", PHASE_FILL[phase], "lh-dot-breathe")}
>
<span className="sr-only">Teacher is working</span>
</span>
);
}
return (
<span
data-testid="session-dot-idle"
aria-hidden
className={cn(
"size-2 shrink-0 rounded-full border-2 bg-transparent",
PHASE_RING[phase],
)}
/>
);
}
/** 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 (
<button
type="button"
onClick={onOpen}
className={cn(
"relative mb-0.5 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent",
active && "bg-accent font-medium",
)}
>
<SessionDot phase={chat.phase} pending={chat.pendingQuestion} busy={chat.busy} />
<span className="min-w-0 flex-1">
<span className="block truncate">{chat.title}</span>
<span className="block text-xs text-muted-foreground">{sessionMeta(chat)}</span>
</span>
</button>
);
}
function SessionsSidebar() {
const { open, toggle } = useSidebar();
const [chats, setChats] = useState<ChatSummary[] | null>(null);
const [chats, setChats] = useState<ChatListItem[] | null>(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 (
<aside
@@ -104,25 +228,14 @@ function SessionsSidebar() {
) : chats.length === 0 ? (
<div className="px-2 py-4 text-sm text-muted-foreground">No sessions yet.</div>
) : (
chats.map((c) => {
const active = pathname === `/chat/${c.id}`;
return (
<button
key={c.id}
type="button"
onClick={() => router.push(`/chat/${c.id}`)}
className={cn(
"mb-0.5 w-full rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent",
active && "bg-accent font-medium",
)}
>
<div className="truncate">{c.title}</div>
<div className="text-xs text-muted-foreground">
{c.messageCount} messages · {new Date(c.updatedAt).toLocaleDateString()}
</div>
</button>
);
})
chats.map((c) => (
<SessionRow
key={c.id}
chat={c}
active={pathname === `/chat/${c.id}`}
onOpen={() => router.push(`/chat/${c.id}`)}
/>
))
)}
</div>
</div>
+551 -51
View File
@@ -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<PanelMode, string> = {
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<PanelMode>("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 <TreePanelModeContext.Provider value={{ mode, go }}>{children}</TreePanelModeContext.Provider>;
}
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 ? (
<TreePanelRail
doneCount={tree.doneNodeIds.length}
totalCount={tree.nodes.length}
onExpand={toggle}
/>
) : (
<TreePanelBody tree={tree} onCollapse={toggle} />
// 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 (
<aside
data-testid="tree-panel"
className={cn(
"sidebar-motion hidden shrink-0 overflow-hidden border-l border-border/60 bg-card/30 duration-200 xl:flex",
PANEL_WIDTH[mode],
)}
>
<div className={cn("flex h-full flex-col", PANEL_WIDTH[mode])}>
{mode === "rail" ? (
<TreePanelRail
doneCount={tree.doneNodeIds.length}
totalCount={tree.nodes.length}
onExpand={() => go("list")}
/>
) : mode === "graph" ? (
<TreeGraphPanel tree={tree} onShowList={() => go("list")} onCollapse={() => go("rail")} />
) : (
<TreePanelBody tree={tree} onShowGraph={() => go("graph")} onCollapse={() => go("rail")} />
)}
</div>
</aside>
);
}
@@ -197,9 +268,9 @@ export function TreePanelRail({
onExpand: () => void;
}) {
return (
<aside
<div
data-testid="tree-panel-rail"
className="hidden w-10 shrink-0 flex-col items-center gap-3 border-l border-border/60 py-3 xl:flex"
className="flex h-full flex-col items-center gap-3 py-3"
>
<button
type="button"
@@ -216,16 +287,19 @@ export function TreePanelRail({
>
{doneCount}/{totalCount}
</span>
</aside>
</div>
);
}
/** 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 (
<aside
data-testid="tree-panel"
className="hidden w-64 shrink-0 flex-col border-l border-border/60 bg-card/30 xl:flex"
>
<div className="flex h-full flex-col">
<header className="flex items-center gap-2 px-3 py-2.5">
<ListTreeIcon className="size-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
@@ -249,6 +320,15 @@ export function TreePanelBody({
>
{doneCount}/{totalCount}
</span>
<button
type="button"
onClick={onShowGraph}
title="Show plan graph"
aria-label="Show plan graph"
className="flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<NetworkIcon className="size-3.5" />
</button>
<button
type="button"
onClick={onCollapse}
@@ -315,7 +395,7 @@ export function TreePanelBody({
})}
</ol>
</nav>
</aside>
</div>
);
}
@@ -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<string, number>;
y: Map<string, number>;
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<string, number>): 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<string, number>();
const y = new Map<string, number>();
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<PlanNodeStatus, string> = {
done: "fill-phase-teach/10 stroke-phase-teach/50",
current: "fill-primary/10 stroke-primary",
upcoming: "fill-card stroke-border",
};
const GRAPH_TEXT: Record<PlanNodeStatus, string> = {
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<HTMLDivElement>(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<GraphView>({ 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div
ref={viewportRef}
data-testid="plan-graph"
className={cn(
"relative min-h-0 flex-1 touch-none overflow-hidden select-none",
atFloor ? "cursor-default" : "cursor-grab",
)}
onPointerDown={startDrag}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div
className="absolute top-0 left-0 origin-top-left"
style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})` }}
>
<svg width={layout.width} height={layout.height} role="img" aria-label="Plan tree graph">
<defs>
<marker
id="lh-plan-arrow"
viewBox="0 0 8 8"
refX="7"
refY="4"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M0,1 L7,4 L0,7 z" className="fill-muted-foreground/50" />
</marker>
</defs>
{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 (
<path
key={`${e.from}->${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 (
<g
key={n.id}
transform={`translate(${layout.x.get(n.id) ?? 0}, ${layout.y.get(n.id) ?? 0})`}
data-testid={`graph-node-${n.id}`}
data-status={status}
data-next={n.id === nextId ? "true" : undefined}
>
<title>{n.summary ? `${n.title} — ${n.summary}` : n.title}</title>
<rect width={NODE_W} height={NODE_H} rx={10} className={GRAPH_BOX[status]} strokeWidth={1.5} />
{status === "current" && (
<circle cx={NODE_W - 12} cy={12} r={3} className="fill-primary" aria-hidden />
)}
{lines.map((line, i) => (
<text
key={i}
x={12}
y={lines.length === 1 ? 29 : 19 + i * 14}
className={GRAPH_TEXT[status]}
>
{line}
</text>
))}
</g>
);
})}
</svg>
</div>
{/* Quiet affordances: the hint and the zoom controls. */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
<span className="text-[10px] text-muted-foreground/60">Drag to pan · scroll to zoom</span>
<div className="flex gap-1">
<button type="button" title="Zoom out" aria-label="Zoom out" onClick={() => stepZoom(1 / 1.25)} className={GRAPH_ZOOM_BTN}>
<MinusIcon className="size-3.5" />
</button>
<button type="button" title="Fit plan to panel" aria-label="Fit plan to panel" onClick={fitView} data-testid="graph-fit" className={GRAPH_ZOOM_BTN}>
<MaximizeIcon className="size-3.5" />
</button>
<button type="button" title="Zoom in" aria-label="Zoom in" onClick={() => stepZoom(1.25)} className={GRAPH_ZOOM_BTN}>
<PlusIcon className="size-3.5" />
</button>
</div>
</div>
</div>
);
}
/** 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 (
<div data-testid="tree-graph-panel" className="flex h-full flex-col">
<header className="flex items-center gap-2 px-3 py-2.5">
<NetworkIcon className="size-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Plan</span>
<span
className="ml-auto text-[11px] tabular-nums text-muted-foreground"
title={`${doneCount} of ${totalCount} nodes done`}
>
{doneCount}/{totalCount}
</span>
<button
type="button"
onClick={onShowList}
title="Show as list"
aria-label="Show as list"
className={GRAPH_HEADER_BTN}
>
<ListTreeIcon className="size-3.5" />
</button>
<button
type="button"
onClick={onCollapse}
title="Hide plan"
aria-label="Hide plan"
className={GRAPH_HEADER_BTN}
>
<ChevronsRightIcon className="size-3.5" />
</button>
</header>
<TreeGraph tree={tree} />
</div>
);
}
+24 -1
View File
@@ -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) ────────────
+107
View File
@@ -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<Phase3, number> = { probe: 0, plan: 1, teach: 2 };
/** Structural slice of a plan tree — anything with nodes + a current node. */
export interface PhaseTree {
nodes: ReadonlyArray<unknown>;
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;
}
+12 -1
View File
@@ -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 [];
+136 -1
View File
@@ -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<typeof tree> }[]
): 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([]), []);
});
});
-43
View File
@@ -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(
<PlanDivider part={{ type: "tool-plan_init", state: "output-available", toolCallId: "p1", output: { ok: true, tree } }} />,
);
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(
<PlanDivider part={{ type: "tool-plan_init", state: "input-streaming", toolCallId: "p1" }} />,
);
assert.equal(streaming, "", "null while input-streaming");
const failed = renderToStaticMarkup(
<PlanDivider
part={{
type: "tool-plan_init",
state: "output-available",
toolCallId: "p1",
output: { ok: false, error: "Tree already initialized", tree },
}}
/>,
);
assert.equal(failed, "", "a refused re-init must not draw a second seam");
});
});
+95
View File
@@ -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<string, unknown>).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(<PhaseChip phase={phase} />);
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(<PhaseDivider phase="plan" nodeCount={3} />);
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(<PhaseDivider phase="teach" />);
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(<PhaseDivider phase="plan" nodeCount={1} />);
assert.match(html, /Plan · 1 node\b/);
});
it("a plan seam without a count still labels itself", () => {
const html = renderToStaticMarkup(<PhaseDivider phase="plan" />);
assert.match(html, /Planning/);
});
it("probe renders null (never a boundary — phases only advance)", () => {
assert.equal(renderToStaticMarkup(<PhaseDivider phase="probe" />), "");
});
});
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);
});
});
+109
View File
@@ -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 <SessionRow> pins every visible
* state. Same tsx classic-JSX shim as render-cards.test.tsx.
*/
import React from "react";
(globalThis as Record<string, unknown>).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>): 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(<SessionRow chat={chat} active={active} onOpen={() => {}} />);
}
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");
});
});
+189
View File
@@ -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(<TreeGraph tree={chainTree} />);
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(<TreeGraph tree={chainTree} />);
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(<TreeGraph tree={tree} />);
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(<TreeGraph tree={tree} />);
const texts = html.match(/<text[^>]*>([^<]*)<\/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(<TreeGraph tree={chainTree} />);
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);
});
});