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/tree-panel.tsx b/components/tree-panel.tsx
index 7f50d10..fe2ee86 100644
--- a/components/tree-panel.tsx
+++ b/components/tree-panel.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useMemo, useRef, useState } from "react";
+import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import {
CheckIcon,
ChevronsRightIcon,
@@ -166,16 +166,25 @@ export function nextNodeId(
// ─── panel ───────────────────────────────────────────────────────────────────
-export function TreePanel({
- messages,
-}: {
- messages: readonly TreePanelMessage[];
-}) {
- const tree = useMemo(() => latestTree(messages), [messages]);
+/**
+ * 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: () => {},
+});
- // Start in the list so server and client first-render agree (no
- // hydration mismatch); the stored preference is applied after mount.
- // Same pattern as the sidebar.
+/** 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(() => {
@@ -193,14 +202,25 @@ export function TreePanel({
}
}, []);
- const go = (next: PanelMode) => {
+ 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 } = 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.