+
+ {/* The explicit END always on the blob's face (SCOPE) — the popover
+ is for DETAIL, not the control. A failed abort locks the button and
+ says why; a succeeded one just makes the blob vanish on refresh. */}
+
+
+ {detailOpen && (
+
+ );
+}
\ No newline at end of file
diff --git a/components/chat.tsx b/components/chat.tsx
index 9415f7d..274ae5c 100644
--- a/components/chat.tsx
+++ b/components/chat.tsx
@@ -20,11 +20,12 @@ import { artifactRenderers } from "@/components/artifacts";
import { SubagentSteerCard } from "@/components/artifacts/steer-card";
import { Composer } from "@/components/composer";
import { JobsContext } from "@/components/jobs-context";
+import { StatsBar } from "@/components/stats-bar";
+import { PlanBlock, PlanPopup } from "@/components/plan-block";
+import type { PlanBlockData } from "@/src/core";
import { QuestionDock } from "@/components/question-dock";
import { SidebarToggle } from "@/components/sidebar";
-import { SubAgentsPanel } from "@/components/sub-agents-panel";
-import { TreePanel } from "@/components/tree-panel";
-import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TeacherUIMessage } from "@/src/core";
+import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TeacherUIMessage, TurnUsage } from "@/src/core";
import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider";
import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core";
@@ -160,7 +161,8 @@ export function Chat({
const syncFromServer = useCallback(async () => {
const res = await fetch(`/api/chats/${chatId}`);
if (!res.ok) return;
- const record = (await res.json()) as { messages?: TeacherUIMessage[] };
+ const record = (await res.json()) as { messages?: TeacherUIMessage[]; turns?: TurnUsage[] };
+ if (Array.isArray(record.turns)) setTurns(record.turns);
if (!Array.isArray(record.messages)) return;
setMessages(record.messages);
// setMessages does NOT fire sendAutomaticallyWhen (the SDK only checks
@@ -175,6 +177,36 @@ export function Chat({
}
}, [chatId, setMessages, sendMessage, status]);
+ // The live plan popup (wave 4): opened by any transcript plan block.
+ const [planOpen, setPlanOpen] = useState(false);
+
+ // ── Session usage (wave-4 stats bar) ──
+ // The money channel: the record's turns log, folded client-side by
+ // core/prices.ts. Seeded once on mount (the page doesn't carry turns),
+ // refreshed when the local stream settles (each turn appends to the log
+ // server-side), and kept fresh by syncFromServer whenever it already
+ // fetches the record. No turns yet → the bar renders "—" everywhere.
+ const [turns, setTurns] = useState([]);
+ const syncTurns = useCallback(async () => {
+ try {
+ const res = await fetch(`/api/chats/${chatId}`);
+ if (!res.ok) return;
+ const record = (await res.json()) as { turns?: TurnUsage[] };
+ if (Array.isArray(record.turns)) setTurns(record.turns);
+ } catch {
+ // transient blip: stats stay on the last known figures
+ }
+ }, [chatId]);
+ useEffect(() => {
+ void syncTurns();
+ }, [syncTurns]);
+ // After the learner's own turn ends the log has a fresh entry — refetch.
+ const prevStatusRef = useRef(status);
+ useEffect(() => {
+ if (prevStatusRef.current !== "ready" && status === "ready") void syncTurns();
+ prevStatusRef.current = status;
+ }, [status, syncTurns]);
+
// ── Central job observability (contracts.ts "THE ASYNC PROTOCOL" §4) ──
// ONE poll of /api/jobs drives every job surface (transcript dispatch
// cards, the sub-agents panel) and re-syncs the transcript exactly once
@@ -360,6 +392,16 @@ export function Chat({
);
case "step-start":
return pi > 0 ? : null;
+ case "data-plan-block":
+ // The plan in the chat (wave 4): a frozen snapshot
+ // block — rendered as carried, never re-derived.
+ return (
+ setPlanOpen(true)}
+ />
+ );
case "tool-quiz":
case "tool-ask_user_question": {
// Pending/streaming questions live in the dock, not the
@@ -400,6 +442,7 @@ export function Chat({
pending={pending}
requiresReveal={requiresReveal}
phase={phases.at(-1) ?? "probe"}
+ turns={turns}
composer={
- {/* Right sidebar, a full-height sibling of the middle column: the
- live sub-agent fleet (when running) above the plan tree — the
- fleet panel renders nothing while idle, so the column is all plan. */}
-
-
-
-
+ {planOpen && setPlanOpen(false)} />}
);
@@ -453,6 +490,7 @@ function DockOverlay({
pending,
requiresReveal,
phase,
+ turns,
composer,
onSubmitAnswer,
}: {
@@ -461,6 +499,8 @@ function DockOverlay({
requiresReveal: boolean;
/** Session phase at the pending question — scales the thinking field. */
phase: Phase3;
+ /** The session's usage log — the stats bar beneath the composer folds it. */
+ turns: TurnUsage[];
composer: React.ReactNode;
onSubmitAnswer: (output: QuestionAnswerData) => void;
}) {
@@ -623,6 +663,10 @@ function DockOverlay({
)}
+ {/* The stats row: beneath the composer/question shell, not
+ flush to the screen bottom — static height, outside the
+ morphing shell so it never joins a height animation. */}
+
diff --git a/components/plan-block.tsx b/components/plan-block.tsx
new file mode 100644
index 0000000..33c6541
--- /dev/null
+++ b/components/plan-block.tsx
@@ -0,0 +1,534 @@
+"use client";
+
+import { useEffect, useId, useMemo, useRef, useState } from "react";
+import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { PlanBlockData, PlanNodeStatus, PlanTree } from "@/src/core";
+import { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
+
+/**
+ * PLAN BLOCKS — the plan lives in the chat (wave 4): persisted
+ * `data-plan-block` parts render as DAG blocks in the transcript flow, and
+ * the right sidebar is retired.
+ *
+ * - kind "full": the whole DAG, BORDERLESS — no wrapping chrome, the SVG
+ * scales to the full column width (viewBox = layout extent, so the
+ * leftmost/rightmost nodes hug the edges), natural height, no cap.
+ * - kind "neighborhood": the fragment in its own small container, labeled
+ * as a FRAGMENT (clearly not the whole plan), the current node glowing.
+ * - Clicking either opens the full plan in a full-screen popup overlay —
+ * the LIVE plan: the latest tree from the transcript with statuses
+ * derived at click time (contracts/plan.ts derivePlanStatuses). Blocks
+ * are frozen snapshots rendered exactly as carried (never re-derived);
+ * the popup is the live view (user decision, 2026-09).
+ *
+ * Hand-built SVG everywhere (user decision): a deterministic layered
+ * layout fills exactly and stays SSR-testable — no force simulation, no
+ * coupling to the graph stream's sigma internals. Pan/zoom in the popup
+ * is harvested from the retired tree panel's graph (fit-floor semantics:
+ * fully zoomed out IS the fit; panning locks at the floor).
+ */
+
+// ─── Layout constants + pure helpers (exported for tests) ────────────────────
+
+export const NODE_W = 160;
+export const NODE_H = 44;
+export const GAP_X = 24;
+export const GAP_Y = 48;
+
+/** A node for layout purposes — the payload's shape and the tree's shape agree. */
+export interface LayoutNode {
+ id: string;
+ title: string;
+}
+
+/** Topological order + longest-path depth (Kahn, authoring tie-break). */
+export function topoFor(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ from: string; to: string }>) {
+ const index = new Map(nodes.map((n, i) => [n.id, i] as const));
+ const indegree = new Map(nodes.map((n) => [n.id, 0] as const));
+ const dependents = new Map();
+ for (const e of edges) {
+ if (!index.has(e.from) || !index.has(e.to)) continue; // defensive: dangling ids
+ indegree.set(e.to, (indegree.get(e.to) ?? 0) + 1);
+ const list = dependents.get(e.from);
+ if (list) list.push(e.to);
+ else dependents.set(e.from, [e.to]);
+ }
+ const ready = nodes.filter((n) => (indegree.get(n.id) ?? 0) === 0).map((n) => n.id);
+ const order: LayoutNode[] = [];
+ const depth = new Map();
+ const byId = new Map(nodes.map((n) => [n.id, n] as const));
+ while (ready.length > 0) {
+ ready.sort((a, b) => (index.get(a) ?? 0) - (index.get(b) ?? 0));
+ const id = ready.shift()!;
+ const node = byId.get(id);
+ if (node) order.push(node);
+ const d = depth.get(id) ?? 0;
+ depth.set(id, d);
+ for (const t of dependents.get(id) ?? []) {
+ if ((depth.get(t) ?? -1) < d + 1) depth.set(t, d + 1);
+ const left = (indegree.get(t) ?? 0) - 1;
+ indegree.set(t, left);
+ if (left === 0) ready.push(t);
+ }
+ }
+ for (const n of nodes) {
+ if (!depth.has(n.id)) {
+ order.push(n);
+ depth.set(n.id, 0);
+ }
+ }
+ return { order, depth };
+}
+
+/** Layered positions: depth = row (top-down), authoring order within the
+ * row, each row centered on the widest one. */
+export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ from: string; to: string }>) {
+ const { order, depth } = topoFor(nodes, edges);
+ const layers: LayoutNode[][] = [];
+ for (const n of order) {
+ const d = depth.get(n.id) ?? 0;
+ if (!layers[d]) layers[d] = [];
+ layers[d].push(n);
+ }
+ const rowWidth = (l: LayoutNode[]) => 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 + Math.max(0, layers.length - 1) * GAP_Y;
+ return { x, y, width: maxWidth, height };
+}
+
+/** Greedy word wrap to ≤ maxLines lines of ≤ maxChars; overflow ends in an
+ * ellipsis, a single oversized word is hard-clamped. Pure. */
+export function wrapTitle(title: string, maxChars = 20, 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 {
+ 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;
+}
+
+// ─── Status styling (the retired panel's color language, unchanged) ──────────
+
+const BOX: Record = {
+ done: "fill-phase-teach/10 stroke-phase-teach/50",
+ current: "fill-primary/10 stroke-primary",
+ upcoming: "fill-card stroke-border",
+};
+const TEXT: Record = {
+ done: "fill-muted-foreground text-xs",
+ current: "fill-foreground text-xs font-medium",
+ upcoming: "fill-muted-foreground/80 text-xs",
+};
+
+/** The diagram itself: positioned nodes + bezier edges. Pure-ish render —
+ * every input is data, so SSR render tests pin the whole thing. */
+function PlanDiagram({
+ nodes,
+ edges,
+ statusOf,
+ glowId,
+ markerId,
+}: {
+ nodes: readonly LayoutNode[];
+ edges: ReadonlyArray<{ from: string; to: string }>;
+ statusOf: (id: string) => PlanNodeStatus;
+ glowId?: string | null;
+ markerId: string;
+}) {
+ const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]);
+ return (
+
+ );
+}
+
+// ─── The transcript block ─────────────────────────────────────────────────────
+
+/**
+ * A persisted plan-block part, rendered as carried (snapshot statuses,
+ * never re-derived). Full = borderless and column-filling; neighborhood =
+ * a labeled fragment container with the current node glowing. Either
+ * opens the live plan popup.
+ */
+export function PlanBlock({ data, onOpen }: { data: PlanBlockData; onOpen: () => void }) {
+ const markerId = useId().replace(/[^a-zA-Z0-9-]/g, "");
+ const statusMap = useMemo(() => new Map(data.nodes.map((n) => [n.id, n.status] as const)), [data]);
+ const statusOf = (id: string): PlanNodeStatus => statusMap.get(id) ?? "upcoming";
+
+ if (data.kind === "full") {
+ return (
+
+ );
+ }
+ return (
+
+ The plan
+ live — statuses derive from your check outcomes
+
+
+
+ {tree ? (
+ <>
+
+
+
+
+ Drag to pan · scroll to zoom
+
+
+
+
+
+
+ >
+ ) : (
+
+ No plan in this session yet.
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx
new file mode 100644
index 0000000..673a1d8
--- /dev/null
+++ b/components/stats-bar.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import { useState } from "react";
+import { contextWindow, sessionCost, turnCost, type TurnUsage } from "@/src/core";
+import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format";
+
+/**
+ * The below-composer stats bar (wave 4 observability): the session's
+ * money/context footprint as one quiet row beneath the composer —
+ * LEFT-aligned: context window + session cost; RIGHT-aligned: model +
+ * thinking level. Clicking the left side opens the per-turn detail popover.
+ *
+ * All folds come from core/prices.ts (sessionCost, contextWindow, turnCost)
+ * — no cost math lives here. Absent data renders "—", never zero (the
+ * doctrine): no turns yet → every figure is "—"; turns the price table
+ * can't price count as unpriced, shown in the popover, never folded into a
+ * fake $0. The RIGHT side is a PLACEHOLDER for the model + thinking-level
+ * control (later work, with its contract changes): display-only for now,
+ * model read from the last turn's usage.
+ */
+export function StatsBar({ turns }: { turns: TurnUsage[] }) {
+ const [open, setOpen] = useState(false);
+ const ctx = contextWindow({ turns });
+ const { cost, unpriced } = sessionCost({ turns });
+ // Honest cost: show dollars only when at least one turn priced;
+ // otherwise "—" (never a fake $0.0000).
+ const pricedCount = turns.length - unpriced;
+ const costLabel = pricedCount > 0 ? formatUsd(cost) : "—";
+ const lastModel = turns.at(-1)?.model;
+
+ return (
+
+
+ {/* Model + thinking level — a placeholder for the coming control
+ (contract changes land with it); display-only today. */}
+
+ );
+}
\ No newline at end of file
diff --git a/components/sub-agents-panel.tsx b/components/sub-agents-panel.tsx
deleted file mode 100644
index 4e644e0..0000000
--- a/components/sub-agents-panel.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-"use client";
-
-import { BotIcon, PenToolIcon, PlayIcon, SearchIcon } from "lucide-react";
-import type { JobKind, JobRecord } from "@/src/core";
-import { useTreePanelMode } from "@/components/tree-panel";
-
-/**
- * The sub-agents sidebar section (vision §2.5 live observability): what the
- * teacher's background fleet is doing RIGHT NOW in this session — one card
- * per running sub-agent with its live activity line (current tool + args)
- * and elapsed time, fed by the central jobs poll.
- *
- * Renders nothing when the fleet is idle (zero clutter); finished jobs
- * vanish from the list — their artifacts live in the transcript. Sits at
- * the top of the right sidebar, above the plan-tree panel.
- */
-
-const KIND_META: Record = {
- research: { label: "Researcher", Icon: SearchIcon },
- demo: { label: "Demo builder", Icon: PlayIcon },
- visual: { label: "Figure maker", Icon: PenToolIcon },
-};
-
-/** The collapsed fleet: a quiet strip on the sidebar rail — bot icon +
- * running count, exactly like the plan's node-count rail below it. Click
- * expands the sidebar back to the full fleet list. */
-function SubAgentsRail({ count, onExpand }: { count: number; onExpand: () => void }) {
- return (
-
-
-
- {count} running
-
-
- );
-}
-
-export function SubAgentsPanel({ jobs }: { jobs: JobRecord[] }) {
- const { mode, go } = useTreePanelMode();
- const running = jobs.filter((j) => j.status === "running");
- if (running.length === 0) return null;
- if (mode === "rail")
- return go("list")} />;
- return (
-
- );
-}
-
-function elapsedOf(job: JobRecord): string {
- if (!job.createdAt) return "";
- const seconds = Math.max(0, Math.round((Date.now() - Date.parse(job.createdAt)) / 1000));
- return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
-}
diff --git a/components/tree-panel.tsx b/components/tree-panel.tsx
deleted file mode 100644
index 1de4af3..0000000
--- a/components/tree-panel.tsx
+++ /dev/null
@@ -1,877 +0,0 @@
-"use client";
-
-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";
-
-/**
-* The persistent plan-tree panel (vision §2 "Persistent tree view", W7).
-*
-* A collapsible column on the right side of the session: the whole tree, at
-* all times, with per-node status (done / current / next / upcoming) derived
-* from the SAME object the teacher edits — no separate state channel. The
-* data source is the transcript itself: every plan_* tool returns the full
-* updated tree in its output, so the panel scans backward for the LATEST
-* output-available plan part and renders its tree. Live during streaming:
-* 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 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).
-*
-* 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: 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.
-* (Same shape-of-shapes trick as src/core/phases.ts.)
-*/
-export interface TreePanelMessage {
- parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }>;
-}
-
-/**
-* The latest plan tree in the transcript: scans messages (and parts) backward
-* for the LAST `tool-plan_*` part with an output available and returns its
-* `output.tree`. A failed op ({ok:false}) still carries the current tree, so
-* it counts; a malformed/missing output falls through to the next candidate.
-* Null when no plan output exists yet (probe phase / pre-plan sessions).
-*/
-export function latestTree(
- messages: readonly TreePanelMessage[],
-): PlanTree | null {
- for (let m = messages.length - 1; m >= 0; m--) {
- const parts = messages[m]?.parts;
- if (!parts) continue;
- for (let p = parts.length - 1; p >= 0; p--) {
- const part = parts[p]!;
- if (
- !part.type.startsWith("tool-plan_") ||
- part.state !== "output-available"
- )
- continue;
- const output = part.output as { tree?: PlanTree } | undefined;
- if (output?.tree && Array.isArray(output.tree.nodes)) return output.tree;
- }
- }
- return null;
-}
-
-/** Topological layout of a tree: display order + dependency depth per node. */
-export interface TopoLayout {
- /** Nodes in topological order (prerequisites first), deterministically tie-broken by authoring order. */
- order: PlanNode[];
- /** Longest-path depth from a root — drives indentation (a DAG, not a tree: a node indents under its DEEPEST prerequisite). */
- depth: Map;
-}
-
-/** Kahn's algorithm over the tree's `requires` edges. Server ops already guarantee a DAG. */
-export function topoLayout(tree: PlanTree): TopoLayout {
- const index = new Map(tree.nodes.map((n, i) => [n.id, i] as const));
- const indegree = new Map(
- tree.nodes.map((n) => [n.id, 0] as const),
- );
- const dependents = new Map();
- for (const e of tree.edges) {
- if (!index.has(e.from) || !index.has(e.to)) continue; // defensive: dangling ids
- indegree.set(e.to, (indegree.get(e.to) ?? 0) + 1);
- const list = dependents.get(e.from);
- if (list) list.push(e.to);
- else dependents.set(e.from, [e.to]);
- }
-
- // Deterministic: among the ready nodes always emit the earliest-authored first.
- const ready = tree.nodes
- .filter((n) => (indegree.get(n.id) ?? 0) === 0)
- .map((n) => n.id);
- const order: PlanNode[] = [];
- const depth = new Map();
- const byId = new Map(tree.nodes.map((n) => [n.id, n] as const));
- while (ready.length > 0) {
- ready.sort((a, b) => (index.get(a) ?? 0) - (index.get(b) ?? 0));
- const id = ready.shift()!;
- const node = byId.get(id);
- if (node) order.push(node);
- let d = 0;
- for (const e of tree.edges) {
- if (e.to === id) d = Math.max(d, (depth.get(e.from) ?? 0) + 1);
- }
- depth.set(id, d);
- for (const t of dependents.get(id) ?? []) {
- const left = (indegree.get(t) ?? 0) - 1;
- indegree.set(t, left);
- if (left === 0) ready.push(t);
- }
- }
- // Unreachable only if the DAG invariant broke; keep every node visible regardless.
- for (const n of tree.nodes) {
- if (!depth.has(n.id)) {
- order.push(n);
- depth.set(n.id, 0);
- }
- }
- return { order, depth };
-}
-
-/**
-* The "next" node (vision §2 lists it as a distinct state): the first
-* upcoming node in topological order whose prerequisites are ALL done — the
-* head of the unlocked frontier. Null when nothing is ready (or all done).
-*/
-export function nextNodeId(
- tree: PlanTree,
- order: readonly PlanNode[],
-): string | null {
- const done = new Set(tree.doneNodeIds);
- for (const n of order) {
- if (planNodeStatus(tree, n.id) !== "upcoming") continue;
- const blocked = tree.edges.some((e) => e.to === n.id && !done.has(e.from));
- if (!blocked) return n.id;
- }
- return null;
-}
-
-// ─── 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: () => {},
-});
-
-/** The right sidebar's shared mode — every section of the sidebar (plan,
- * sub-agents) collapses and expands in lockstep through this hook. */
-export function useTreePanelMode() {
- return useContext(TreePanelModeContext);
-}
-
-/** 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]);
- const { mode, go } = useTreePanelMode();
-
- // 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;
-
- // 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 (
-
- );
-}
-
-/** The collapsed panel: a thin strip with the map icon + quiet progress. */
-export function TreePanelRail({
- doneCount,
- totalCount,
- onExpand,
-}: {
- doneCount: number;
- totalCount: number;
- onExpand: () => void;
-}) {
- return (
-