Tree panel mode lives in the root layout: no open-then-close on session open

The persisted mode (lh:tree-panel-mode) was read inside TreePanel — a
per-page component that remounts on every session navigation. First
paint rendered the SSR-safe "list" default, then the mount effect
applied the stored mode: with the animated frame, opening a session
visibly opened the panel and then closed it. The mode now lives in a
root-layout provider (the SidebarProvider pattern) and survives
navigations untouched — the panel mounts already in the right mode.
The default stays "list" so SSR and first client render agree; the
stored preference applies once, on provider mount.
This commit is contained in:
amosblomqvist
2026-09-02 02:43:28 +03:00
parent 8dda21ce1b
commit fe88fa87ec
2 changed files with 36 additions and 13 deletions
+4 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import "katex/dist/katex.min.css"; import "katex/dist/katex.min.css";
import "./globals.css"; import "./globals.css";
import { AppShell, SidebarProvider } from "@/components/sidebar"; import { AppShell, SidebarProvider } from "@/components/sidebar";
import { TreePanelModeProvider } from "@/components/tree-panel";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Learning Harness", title: "Learning Harness",
@@ -13,7 +14,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html lang="en" className="dark"> <html lang="en" className="dark">
<body className="min-h-dvh antialiased"> <body className="min-h-dvh antialiased">
<SidebarProvider> <SidebarProvider>
<AppShell>{children}</AppShell> <TreePanelModeProvider>
<AppShell>{children}</AppShell>
</TreePanelModeProvider>
</SidebarProvider> </SidebarProvider>
</body> </body>
</html> </html>
+32 -12
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { import {
CheckIcon, CheckIcon,
ChevronsRightIcon, ChevronsRightIcon,
@@ -166,16 +166,25 @@ export function nextNodeId(
// ─── panel ─────────────────────────────────────────────────────────────────── // ─── panel ───────────────────────────────────────────────────────────────────
export function TreePanel({ /**
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
messages: readonly TreePanelMessage[]; * would paint its default first and then "correct" to the stored mode —
}) { * with the animated frame, opening a session visibly opened-then-closed
const tree = useMemo(() => latestTree(messages), [messages]); * 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 /** Root-layout persistence of the panel mode (rail | list | graph). */
// hydration mismatch); the stored preference is applied after mount. export function TreePanelModeProvider({ children }: { children: React.ReactNode }) {
// Same pattern as the sidebar. // 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"); const [mode, setMode] = useState<PanelMode>("list");
useEffect(() => { useEffect(() => {
@@ -193,14 +202,25 @@ export function TreePanel({
} }
}, []); }, []);
const go = (next: PanelMode) => { const go = useCallback((next: PanelMode) => {
setMode(next); setMode(next);
try { try {
window.localStorage.setItem(MODE_KEY, next); window.localStorage.setItem(MODE_KEY, next);
} catch { } catch {
// see above // see above
} }
}; }, []);
return <TreePanelModeContext.Provider value={{ mode, go }}>{children}</TreePanelModeContext.Provider>;
}
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 // 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. // the moment plan_init's output lands and updates live from there.