diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts
index cb4c31b..7ac8e5a 100644
--- a/app/api/chats/route.ts
+++ b/app/api/chats/route.ts
@@ -1,7 +1,16 @@
-import { createChat, listChats, titleFromPrompt } from "@/src/server";
+import { createChat, isTurnRunning, listChats, listJobs, titleFromPrompt } from "@/src/server";
+import type { ChatListItem } from "@/src/core";
+/** The session picker's list: record-derived summaries (phase, pending
+ * question) + live liveness — a running turn OR a background job marks
+ * the session busy (contracts.ts ChatListItem; the store can't see
+ * liveness, the route is the merge point). */
export async function GET() {
- return Response.json({ chats: listChats() });
+ const chats: ChatListItem[] = listChats().map((c) => ({
+ ...c,
+ busy: isTurnRunning(c.id) || listJobs(c.id).some((j) => j.status === "running"),
+ }));
+ return Response.json({ chats });
}
export async function POST(req: Request) {
diff --git a/app/globals.css b/app/globals.css
index a123a02..7c824ce 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -157,3 +157,55 @@ body {
overflow-x: auto;
overflow-y: hidden;
}
+
+/* ============================================================
+ * Session status — phase colors + status dots
+ * (picker strips, header chip, transcript dividers: one language)
+ * ============================================================ */
+
+/* Phase colors: blue = probing, violet = planning, green = teaching.
+ Tuned per theme (light needs darker, dark lighter). These color the
+ session dots, header chip and transcript dividers — one language. */
+:root {
+ --phase-probe: oklch(0.55 0.17 253);
+ --phase-plan: oklch(0.54 0.19 300);
+ --phase-teach: oklch(0.55 0.14 160);
+}
+
+.dark {
+ --phase-probe: oklch(0.72 0.13 250);
+ --phase-plan: oklch(0.72 0.14 300);
+ --phase-teach: oklch(0.74 0.12 160);
+}
+
+@theme inline {
+ --color-phase-probe: var(--phase-probe);
+ --color-phase-plan: var(--phase-plan);
+ --color-phase-teach: var(--phase-teach);
+}
+
+/* Session dot motion (picker): the ONLY animated state is "teacher is
+ working" — a quiet phase-colored breathing. Pending questions are the
+ dominant resting state and stay perfectly still (solid dot). */
+@keyframes lh-dot-breathe {
+ 0%,
+ 100% {
+ opacity: 0.35;
+ transform: scale(0.75);
+ }
+
+ 50% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+.lh-dot-breathe {
+ animation: lh-dot-breathe 1.4s ease-in-out infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .lh-dot-breathe {
+ animation: none;
+ }
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 201bc87..f8c1df1 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import "katex/dist/katex.min.css";
import "./globals.css";
import { AppShell, SidebarProvider } from "@/components/sidebar";
+import { TreePanelModeProvider } from "@/components/tree-panel";
export const metadata: Metadata = {
title: "Learning Harness",
@@ -13,7 +14,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
- {children}
+
+ {children}
+
diff --git a/components/artifacts/index.ts b/components/artifacts/index.ts
index bf6f87e..dc6eea2 100644
--- a/components/artifacts/index.ts
+++ b/components/artifacts/index.ts
@@ -2,7 +2,6 @@ import type { ComponentType } from "react";
import type { ArtifactPartProps } from "@/src/core";
import { DemoCard } from "@/components/artifacts/demo-card";
import { JobCard } from "@/components/artifacts/job-card";
-import { PlanDivider } from "@/components/artifacts/plan-divider";
import { VisualCard } from "@/components/artifacts/visual-card";
/**
@@ -19,8 +18,9 @@ import { VisualCard } from "@/components/artifacts/visual-card";
*/
export const artifactRenderers: Record> = {
research: JobCard,
- // Phase divider where the plan lands (W5 fills in the component):
- plan_init: PlanDivider,
+ // plan_* parts render nothing inline by design: the phase seam is a
+ // message-level boundary, drawn by PhaseDivider in chat.tsx (the old
+ // part-level plan_init divider retired with it).
// Artifacts (W6 fills in the components):
demo: DemoCard,
visual: VisualCard,
diff --git a/components/artifacts/plan-divider.tsx b/components/artifacts/plan-divider.tsx
deleted file mode 100644
index e3ac6fd..0000000
--- a/components/artifacts/plan-divider.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import type { ArtifactPartProps, PlanToolResult } from "@/src/core";
-
-/**
- * The probe→teach divider (vision §2 "Two phases, clearly separated").
- *
- * Rendered by the artifact registry wherever the session's plan_init part
- * sits in the transcript — the plan call IS the transition marker, so the
- * divider marks the seam: probing above, teaching below. Deliberately quiet:
- * a hairline rule with a small chip, never a banner.
- *
- * Renders null until the tool's output is available AND the init succeeded —
- * a failed first plan_init must not mark the seam (the retry that succeeds
- * draws the divider at its own, correct spot).
- */
-export function PlanDivider({ part }: ArtifactPartProps) {
- if (part.state !== "output-available") return null;
- const result = part.output as PlanToolResult | undefined;
- if (!result || result.ok !== true) return null;
- const nodeCount = result.tree?.nodes?.length ?? 0;
- return (
-
- );
-}
diff --git a/components/chat.tsx b/components/chat.tsx
index 2f1557e..d50e312 100644
--- a/components/chat.tsx
+++ b/components/chat.tsx
@@ -1,6 +1,6 @@
"use client";
-import { createContext, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { createContext, Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import { useStickToBottomContext } from "use-stick-to-bottom";
@@ -24,7 +24,8 @@ import { SidebarToggle } from "@/components/sidebar";
import { SubAgentsPanel } from "@/components/sub-agents-panel";
import { TreePanel } from "@/components/tree-panel";
import type { ArtifactPartProps, JobRecord, QuestionAnswerData, TeacherUIMessage } from "@/src/core";
-import { findPendingToolCall, phaseAt } from "@/src/core";
+import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider";
+import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core";
/** One width for everything: transcript, cards, and the bottom blob share this column. */
const COLUMN = "mx-auto w-full max-w-3xl px-4";
@@ -127,13 +128,18 @@ export function Chat({
const localStream = status === "submitted" || status === "streaming";
const busy = localStream || turnInFlight;
- // Teach-phase check (W5, vision §2 "two phases"): a pending question that
- // lands after plan_init starts collapsed behind a "Check your
- // understanding" reveal button; probe-phase questions auto-open as today.
- // A pending question can never precede plan_init in the transcript (the
- // teacher's turn ends at the question call), so the session's current
- // phase IS the question's phase. Pre-plan sessions: all probe — unchanged.
- const requiresReveal = pending !== null && phaseAt(messages) === "teach";
+ // Teach-phase check (W5, vision §2): a pending question that lands once a
+ // plan node is OCCUPIED (phaseAt3 "teach") starts collapsed behind a
+ // "Check your understanding" reveal button; probe- and plan-phase
+ // questions auto-open as today. A pending question can never precede its
+ // phase's seam in the transcript (the teacher's turn ends at the question
+ // call), so the session's current phase IS the question's phase.
+ const requiresReveal = pending !== null && phaseAt3(messages) === "teach";
+
+ // The phase at every message (one linear fold): the transcript draws a
+ // color-coded dotted divider at each boundary; the header chip reads the
+ // session's current phase.
+ const phases = useMemo(() => phaseTimeline(messages), [messages]);
// A tool call still streaming its input: the teacher is writing a question.
// Shown as a transcript shimmer, exactly like the thinking indicator.
@@ -267,16 +273,23 @@ export function Chat({
return (
void sendMessage({ text: t }) }}>
-
-
-
-
{title}
-
+ {/* The session surface: the transcript column (header + scroller) and
+ the plan-tree panel as full-height SIBLINGS — the panel spans top to
+ bottom exactly like the left sidebar, and the header (name + phase
+ chip) spans from the left sidebar's edge to the panel's edge. The
+ dock stays sticky inside the scroller, untouched. */}
+
+
+ {/* h-10, not py-2: the toggle (a size-9 icon button) renders only
+ while the sidebar is CLOSED — an auto-height row would collapse
+ when it disappears and slide the title + chip up. */}
+
+
+
{title}
+
+
- {/* The session row: transcript scroller + the persistent plan-tree panel
- (W7, vision §2). The panel is a flex SIBLING of the scroller — the dock
- stays sticky inside the scroller, untouched. */}
-
+
@@ -296,7 +309,11 @@ export function Chat({
// can still hold empty ids — the index keeps keys unique for them.
) : (
-
+
+ {mi > 0 && phases[mi] !== phases[mi - 1] && (
+
+ )}
+
{m.role === "user" ? (
// User text renders plain: the model's LaTeX pipeline is not
@@ -342,7 +359,8 @@ export function Chat({
})
)}
-
+
+
))}
{(status === "submitted" || turnInFlight) && Thinking…}
@@ -381,11 +399,13 @@ export function Chat({
- {/* Right sidebar: live sub-agent fleet (when running) above the plan tree. */}
+ {/* Right sidebar: the live sub-agent fleet (when running) above
+ the plan tree — the fleet panel renders nothing while idle. */}
+
diff --git a/components/phase-divider.tsx b/components/phase-divider.tsx
new file mode 100644
index 0000000..0e9697b
--- /dev/null
+++ b/components/phase-divider.tsx
@@ -0,0 +1,99 @@
+import { cn } from "@/lib/utils";
+import type { Phase3 } from "@/src/core";
+
+/**
+ * The in-session phase presentation — the explicit face of what the picker
+ * shows as a color strip (one color system everywhere, globals.css
+ * --phase-*): the header chip next to the session title, and the dotted
+ * boundary rule the transcript draws where the session moves into a new
+ * phase. Replaces the old inline plan-divider (artifact registry): the
+ * boundary is a MESSAGE-level seam, so chat.tsx renders it at the message
+ * where the phase changes, not inside a tool part.
+ *
+ * Quiet register (matches the old divider): hairline rules, small chips —
+ * never a banner. Probe never renders a divider: phases only advance, so
+ * probe is the start of the transcript and "Planning"/"Teaching" are the two
+ * visible seams.
+ */
+
+/** Header-chip labels: the phases as the learner thinks of them. */
+const CHIP_LABEL: Record = {
+ probe: "Probing",
+ plan: "Planning",
+ teach: "Teaching",
+};
+
+const PHASE_DOT: Record = {
+ probe: "bg-phase-probe",
+ plan: "bg-phase-plan",
+ teach: "bg-phase-teach",
+};
+
+/** The header chip: a phase-colored dot + label next to the session title. */
+export function PhaseChip({ phase }: { phase: Phase3 }) {
+ return (
+
+
+ {CHIP_LABEL[phase]}
+
+ );
+}
+
+/** Divider rule + chip colors per phase (probe never reaches here). */
+const DIVIDER_RULE: Partial> = {
+ plan: "border-phase-plan/50",
+ teach: "border-phase-teach/50",
+};
+const DIVIDER_TEXT: Partial> = {
+ plan: "border-phase-plan/50 text-phase-plan",
+ teach: "border-phase-teach/50 text-phase-teach",
+};
+
+/**
+ * The transcript boundary: a color-coded dotted rule at the message where
+ * the session enters `phase`. The plan seam carries its node count ("Plan ·
+ * 4 nodes"); teaching is just the label. Null for probe (defensive — the
+ * fold never emits a probe transition after the start).
+ */
+export function PhaseDivider({ phase, nodeCount }: { phase: Phase3; nodeCount?: number }) {
+ if (phase === "probe") return null;
+ const label =
+ phase === "plan" && nodeCount
+ ? `Plan · ${nodeCount} node${nodeCount === 1 ? "" : "s"}`
+ : CHIP_LABEL[phase];
+ return (
+
+
+
+ {label}
+
+
+
+ );
+}
+
+/**
+ * The node count for a plan seam: the first plan-op tree output in the
+ * message that carries the seam (the plan_init that made the phase flip).
+ * Structural slice of a UI message, same shape-of-shapes as core/phases.ts.
+ */
+export function planNodeCount(
+ message: { parts: ReadonlyArray<{ type: string; state?: string; output?: unknown; [k: string]: unknown }> },
+): number | undefined {
+ for (const part of message.parts) {
+ if (!part.type.startsWith("tool-plan_") || part.state !== "output-available") continue;
+ const tree = (part.output as { tree?: { nodes?: unknown[] } } | undefined)?.tree;
+ if (tree && Array.isArray(tree.nodes) && tree.nodes.length > 0) return tree.nodes.length;
+ }
+ return undefined;
+}
diff --git a/components/sidebar.tsx b/components/sidebar.tsx
index d705b13..8eeb6c0 100644
--- a/components/sidebar.tsx
+++ b/components/sidebar.tsx
@@ -6,7 +6,7 @@ import { usePathname, useRouter } from "next/navigation";
import { PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
-import type { ChatSummary } from "@/src/core";
+import type { ChatListItem, Phase3 } from "@/src/core";
/**
* Toggleable sidebar — NOT a modal drawer: no backdrop, no focus theft. It
@@ -65,23 +65,147 @@ export function SidebarToggle() {
);
}
+/** Phase colors for the session dot — one system with the header chip
+ * and the transcript dividers (globals.css --phase-*). Static class
+ * strings per phase: Tailwind can't compile dynamic class names. */
+const PHASE_FILL: Record = {
+ probe: "bg-phase-probe",
+ plan: "bg-phase-plan",
+ teach: "bg-phase-teach",
+};
+const PHASE_RING: Record = {
+ probe: "border-phase-probe",
+ plan: "border-phase-plan",
+ teach: "border-phase-teach",
+};
+
+/**
+ * The session dot, left of the title. TWO channels, one dot: COLOR is the
+ * phase (probe/plan/teach — sky/violet/emerald), FILL+MOTION is what's
+ * owed. Pulsing = the teacher is working (rare, time-sensitive — the only
+ * state that deserves motion). Solid = a question awaits the learner's
+ * answer (the DOMINANT resting state of real sessions — calm by design,
+ * no animation). Hollow = nothing owed, idle. A pending answer outranks
+ * "working": if a question is owed, that's the blocking fact.
+ */
+function SessionDot({ phase, pending, busy }: { phase: Phase3; pending: boolean; busy: boolean }) {
+ if (pending) {
+ return (
+
+ Waiting for your answer
+
+ );
+ }
+ if (busy) {
+ return (
+
+ Teacher is working
+
+ );
+ }
+ return (
+
+ );
+}
+
+/** Compact "how long ago": "just now", "5m ago", "3h ago", "2d ago",
+ * then a plain date. How long a session has been WAITING matters more
+ * than an absolute timestamp. Pure — exported for tests. */
+export function timeAgo(iso: string, now = new Date()): string {
+ const ms = now.getTime() - new Date(iso).getTime();
+ if (ms < 60_000) return "just now";
+ const min = Math.floor(ms / 60_000);
+ if (min < 60) return `${min}m ago`;
+ const h = Math.floor(min / 60);
+ if (h < 24) return `${h}h ago`;
+ const d = Math.floor(h / 24);
+ if (d < 7) return `${d}d ago`;
+ return new Date(iso).toLocaleDateString();
+}
+
+/** The row's second line: state replaces the message count when the state
+ * is the more useful fact (most resting sessions are awaiting an answer —
+ * that's what the learner scans for). */
+function sessionMeta(chat: ChatListItem): string {
+ if (chat.pendingQuestion) return `Waiting for your answer · ${timeAgo(chat.updatedAt)}`;
+ if (chat.busy) return "Teacher is working…";
+ return `${chat.messageCount} messages · ${new Date(chat.updatedAt).toLocaleDateString()}`;
+}
+
+/**
+ * One session row: the status dot, then title + state-aware meta. Exported
+ * for render tests — pure function of the list item.
+ */
+export function SessionRow({
+ chat,
+ active,
+ onOpen,
+}: {
+ chat: ChatListItem;
+ active: boolean;
+ onOpen: () => void;
+}) {
+ return (
+
+ );
+}
+
function SessionsSidebar() {
const { open, toggle } = useSidebar();
- const [chats, setChats] = useState(null);
+ const [chats, setChats] = useState(null);
const pathname = usePathname();
const router = useRouter();
+ const refresh = useCallback(async () => {
+ try {
+ const res = await fetch("/api/chats", { cache: "no-store" });
+ const d = (await res.json()) as { chats: ChatListItem[] };
+ setChats(d.chats);
+ } catch {
+ // a transient failure on the periodic poll keeps the last snapshot
+ setChats((prev) => prev ?? []);
+ }
+ }, []);
+
useEffect(() => {
if (!open) return;
- let cancelled = false;
- fetch("/api/chats", { cache: "no-store" })
- .then((r) => r.json())
- .then((d: { chats: ChatSummary[] }) => !cancelled && setChats(d.chats))
- .catch(() => !cancelled && setChats([]));
- return () => {
- cancelled = true;
- };
- }, [open, pathname]);
+ void refresh();
+ // Poll while open: the statuses are live (a question lands in another
+ // session, a turn finishes in the background) and the payload is tiny.
+ // Pause while the tab is hidden; navigation refetches via pathname.
+ const id = setInterval(() => {
+ if (!document.hidden) void refresh();
+ }, 2500);
+ return () => clearInterval(id);
+ }, [open, pathname, refresh]);
return (
diff --git a/components/tree-panel.tsx b/components/tree-panel.tsx
index 7865c1b..fe2ee86 100644
--- a/components/tree-panel.tsx
+++ b/components/tree-panel.tsx
@@ -1,7 +1,15 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
-import { CheckIcon, ChevronsRightIcon, ListTreeIcon } from "lucide-react";
+import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
+import {
+ CheckIcon,
+ ChevronsRightIcon,
+ ListTreeIcon,
+ MaximizeIcon,
+ MinusIcon,
+ NetworkIcon,
+ PlusIcon,
+} from "lucide-react";
import { cn } from "@/lib/utils";
import { planNodeStatus, type PlanNode, type PlanNodeStatus, type PlanTree } from "@/src/core";
@@ -17,17 +25,35 @@ import { planNodeStatus, type PlanNode, type PlanNodeStatus, type PlanTree } fro
* set_current / mark_done outputs flip the panel mid-session.
*
* The panel and the agent's tools share one object with two faces (agent
-* edits it, UI renders it) — the divider in the transcript (plan-divider)
-* marks the seam; THIS panel is the surface for every other plan_* part
-* (they render nothing inline, by design).
+* edits it, UI renders it) — the phase seam in the transcript
+* (PhaseDivider, message-level) marks the transition; THIS panel is the
+* surface for every plan_* part (they render nothing inline, by design).
*
-* Quiet register (matches plan-divider): hairline border, muted text, one
-* highlighted row for the current position. Collapses to a thin rail; the
-* preference persists in localStorage. Hidden below `xl` (CSS-only).
+* Three modes, one persisted preference: the LIST (nested — the default,
+* and the always-visible "collapsed" view), the GRAPH (expanded: a
+* hand-built mermaid-style diagram — full styling control, no mermaid
+* renderer — with drag-pan + wheel-zoom whose FLOOR is fit-to-panel), and
+* the RAIL (thin strip). Quiet register throughout (matches the phase
+* dividers): hairline borders, muted text, one highlighted node for the
+* current position. Hidden below `xl` (CSS-only).
*/
-/** localStorage key for the collapse preference (matches lh:sidebar-open). */
-const STORAGE_KEY = "lh:tree-panel-collapsed";
+/** localStorage: the mode persists as "lh:tree-panel-mode"
+ * ("rail" | "list" | "graph"); the pre-graph key
+ * ("lh:tree-panel-collapsed": "1" rail / "0" list) is the fallback. */
+/** The panel width per mode — the aside AND its fixed inner layer (see
+ * TreePanel: the inner layer must not squish while the aside animates). */
+const PANEL_WIDTH: Record = {
+ rail: "w-10",
+ list: "w-64",
+ graph: "w-96",
+};
+
+const MODE_KEY = "lh:tree-panel-mode";
+const LEGACY_KEY = "lh:tree-panel-collapsed";
+
+/** The three panel modes: rail (fully collapsed) | list (default) | graph. */
+type PanelMode = "rail" | "list" | "graph";
/**
* Minimal structural slice of a UI message — anything with typed parts.
@@ -140,49 +166,94 @@ export function nextNodeId(
// ─── panel ───────────────────────────────────────────────────────────────────
+/**
+ * The panel mode lives in the ROOT layout, not in TreePanel. TreePanel is
+ * per-page (chat sessions remount it on every navigation), so a local copy
+ * would paint its default first and then "correct" to the stored mode —
+ * with the animated frame, opening a session visibly opened-then-closed
+ * the panel. In the root layout the mode survives navigations untouched,
+ * exactly like the left sidebar's open state (SidebarProvider).
+ */
+const TreePanelModeContext = createContext<{ mode: PanelMode; go: (next: PanelMode) => void }>({
+ mode: "list",
+ go: () => {},
+});
+
+/** Root-layout persistence of the panel mode (rail | list | graph). */
+export function TreePanelModeProvider({ children }: { children: React.ReactNode }) {
+ // SSR default: "list" — server and client first-render agree (no
+ // hydration mismatch). The stored preference applies on mount, and
+ // the provider itself never remounts, so after that first correction
+ // the mode is simply THERE on every session you open.
+ const [mode, setMode] = useState("list");
+
+ useEffect(() => {
+ try {
+ const stored = window.localStorage.getItem(MODE_KEY);
+ if (stored === "rail" || stored === "list" || stored === "graph") {
+ setMode(stored);
+ } else {
+ // legacy key (pre-graph): "1" = the collapsed rail
+ const legacy = window.localStorage.getItem(LEGACY_KEY);
+ if (legacy === "1") setMode("rail");
+ }
+ } catch {
+ // private mode / storage disabled — the preference is simply not persisted
+ }
+ }, []);
+
+ const go = useCallback((next: PanelMode) => {
+ setMode(next);
+ try {
+ window.localStorage.setItem(MODE_KEY, next);
+ } catch {
+ // see above
+ }
+ }, []);
+
+ return {children};
+}
+
export function TreePanel({
messages,
}: {
messages: readonly TreePanelMessage[];
}) {
const tree = useMemo(() => latestTree(messages), [messages]);
-
- // Start expanded so server and client first-render agree (no hydration
- // mismatch); the stored preference is applied after mount. Same pattern
- // as the sidebar.
- const [collapsed, setCollapsed] = useState(false);
-
- useEffect(() => {
- try {
- const stored = window.localStorage.getItem(STORAGE_KEY);
- if (stored !== null) setCollapsed(stored === "1");
- } catch {
- // private mode / storage disabled — the preference is simply not persisted
- }
- }, []);
-
- const toggle = () => {
- const next = !collapsed;
- setCollapsed(next);
- try {
- window.localStorage.setItem(STORAGE_KEY, next ? "1" : "0");
- } catch {
- // see above
- }
- };
+ const { mode, go } = useContext(TreePanelModeContext);
// Empty state: no tree yet → the panel does not render at all. It appears
// the moment plan_init's output lands and updates live from there.
if (!tree || tree.nodes.length === 0) return null;
- return collapsed ? (
-
- ) : (
-
+ // ONE animated container, exactly like the left sidebar: the aside
+ // animates its width (sidebar-motion transition cascades from the
+ // provider and is mount-gated there) while the inner layer keeps a
+ // FIXED width and is clipped by overflow-hidden — content never
+ // reflows mid-transition, so no scrollbar flash or overshoot. The
+ // mode's content just swaps instantly inside the steady frame.
+ return (
+
);
}
@@ -197,9 +268,9 @@ export function TreePanelRail({
onExpand: () => void;
}) {
return (
-
+
);
}
-/** The expanded panel: header + the node list in topological order. */
+/** The expanded panel (list mode): header + the node list in topological order. */
export function TreePanelBody({
tree,
+ onShowGraph,
onCollapse,
}: {
tree: PlanTree;
+ /** Expand into the mermaid-style graph view. */
+ onShowGraph: () => void;
onCollapse: () => void;
}) {
const { order, depth } = useMemo(() => topoLayout(tree), [tree]);
@@ -234,10 +308,7 @@ export function TreePanelBody({
const totalCount = tree.nodes.length;
return (
-