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.
- The three panel modes were three separate fixed-width asides, so a
mode switch jumped the whole transcript column width in one frame:
scrollbar flash, overshoot, re-correct. Now ONE aside animates its
width exactly like the left sidebar (reusing the sidebar-motion
hook, so it inherits the provider's mount-gated transition), with
a FIXED-width inner layer clipped by overflow-hidden — content
never reflows mid-transition.
- The chat header was auto-height: the sidebar toggle (a size-9 icon
button) renders only while the sidebar is closed, so opening it
collapsed the row and slid the title + phase chip up. The header is
now a constant h-10.
- Chat page: the tree panel is now a full-height sibling of the whole
transcript column (header + scroller), exactly like the left sidebar —
the session header (name + phase chip) spans from the left sidebar's
edge to the panel's edge instead of riding over the panel.
- Plan graph: the zoom controls sit inside the pan surface, so their
pointerdown started a capture drag and the click never reached the
button (felt dead). startDrag now ignores presses on buttons.
- Fully zoomed out IS the fit view: zooming to the floor snaps to the
centered fit (pure zoomedGraphView), pan is locked there (no drag
begins, cursor-default instead of grab), and sitting at the floor
re-arms auto-fit so later tree edits keep the graph fitting.
- Fit padding 48px → 16px: the default view fits more graph.
The nested list stays as the default/collapsed view; the new expand
toggle (list header) opens the graph — a hand-built SVG diagram
(mermaid STYLE, no mermaid renderer: full control of the styling so it
speaks this app's design language):
- Top-down layered layout (topoLayout depth = rows, authoring order
within a row, rows centered on the widest) — vertical space is the
cheap axis; boxes + vertical bezier arrows with arrowheads.
- Status coloring matches the list marks: done nodes are colored in
(teach-phase green tint — the graph fills in as the session
progresses), current = the highlighted position, next = unlocked
ring, upcoming = quiet.
- Drag pan + wheel zoom (to the cursor, non-passive), +/- and
fit-to-panel buttons. The zoom FLOOR is fit: fully zoomed out, the
whole diagram always fits the panel — it can never disappear into
nothing. Auto-fits on measure/layout change until the learner takes
manual control; fit re-enables it.
- Panel widens to w-96 in graph mode; mode persists
(lh:tree-panel-mode: rail|list|graph, legacy key migrated).
- wrapTitle + graphLayout pure, unit-tested; TreeGraph SSR-render
tested (statuses, arrows, wrapping, data-next).
- components/phase-divider.tsx: PhaseChip (header, next to the session
title: phase-colored dot + Probing/Planning/Teaching) and PhaseDivider
(the transcript boundary: color-coded dotted rules + a small chip —
'Plan · N nodes' at the plan seam, 'Teaching' at the teach seam; probe
never renders, phases only advance). planNodeCount reads the seam
message's first plan tree.
- chat.tsx: dividers render at MESSAGE boundaries (phaseTimeline, one
linear fold) — the seam is the transition to the message's phase, so
the seam message's prose sits below its divider. requiresReveal now
uses phaseAt3 teach (an occupied node), so probe- AND plan-phase
questions auto-open.
- Retired the part-level plan-divider (artifact registry): the boundary
is message-level, not part-level. Its tests replaced by
render-phase-divider tests; core/phases gains tested phaseTimeline.
The shell now imports through exactly two barrels, enforced by
import/no-restricted-paths (npm run lint):
components/** -> "@/src/core" (pure core API, browser-safe)
app/** -> "@/src/core" + "@/src/server" (server entries)
Zone violations are lint errors with architecture-pointer messages; the
pure-core modules (contracts, question-core, plan-tree, phases, pending,
math-delims, ui-message) may never import agent/server/store.
Supporting moves (direction: IO layers -> core):
- core/index.ts: the core public API barrel
- core/ui-message.ts: moved from src/agent (it was shell-facing all along)
- quiz/ask input zod schemas moved agent/tools -> core/question-core
(the question contract, alongside the grading logic)
- ChatRecord/ChatSummary moved store/chats -> core/contracts (single schema)
- GraphSnapshot/NodeStateClass moved core/graph -> core/contracts (the
/api/graph payload IS a contract; the pure renderer needs only these)
- src/server/{chats,graph,jobs}.ts + index.ts: thin server entries for the
transport layer; GET /api/graph's fold side-effect now lives behind
graphView() instead of inline in the route
Gates: tsc clean, 190/190 tests, eslint clean (law verified to fire on
store/agent/subpath violations), production build clean.
(Repo repair en route: a git object (app/chat/[id] tree) was missing from
the object store — reconstructed byte-exactly via mktree from its blob;
the broken node_modules that stalled the build was cleanly reinstalled.)