From 93033fac7787d97c753b493083c32e5de755ddc2 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 11:23:28 +0300 Subject: [PATCH] =?UTF-8?q?Plan=20placement=20(owner):=20the=20plan=20as?= =?UTF-8?q?=20marginalia=20=E2=80=94=20dock=20band's=20right=20flank=20+?= =?UTF-8?q?=20top-bar=20chip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retires the fixed bottom-left plan window (which sat ON the sidebar and bled into the session). Two placements replace it, per the owner's lock-in: - The plan chip in the top bar beside the phase chip — session-level structure beside session state; opens the full-DAG popup; present at every width; the access point. - The plan marginalia: the BARE diagram (no card, no border, no shadow) in the dock band's right flank — marginalia on the lesson's page, not a floating widget. The band's reserved height (tall while a quiz is open) is its canvas; meet-fitted (PlanDiagram's new fill mode) so it fits both the flank's width and the band's height; live statuses + the yellow current-node glow; click opens the popup. Mounted only while a question owns the band — composer mode keeps the reading view pristine. Folding the dock fades + blurs it out of existence (300ms), returning to the bottom fades it back in. Rendered only when the flank holds a real diagram (>= 240px); narrower windows keep the chip alone. Files: chat.tsx (chip, DockOverlay marginalia wrapper + flank measure, PlanWindow removed), plan-window.tsx (PlanWindow → PlanMarginalia), plan-block.tsx (PlanDiagram fill prop: h-full w-full meet-fit), tests/plan-window.test.tsx (rewritten for the marginalia). --- components/chat.tsx | 87 +++++++++++++++++++++++++++--- components/plan-block.tsx | 8 ++- components/plan-window.tsx | 108 +++++++++++-------------------------- tests/plan-window.test.tsx | 92 ++++++++++--------------------- 4 files changed, 146 insertions(+), 149 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index 44396b7..d338368 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -23,7 +23,8 @@ import { JobsContext } from "@/components/jobs-context"; import { StatsBar } from "@/components/stats-bar"; import type { ChatSettings } from "@/src/core"; import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block"; -import { PlanWindow } from "@/components/plan-window"; +import { PlanMarginalia } from "@/components/plan-window"; +import { WaypointsIcon } from "lucide-react"; import type { PlanBlockData } from "@/src/core"; import { QuestionDock } from "@/components/question-dock"; import { SidebarToggle } from "@/components/sidebar"; @@ -40,6 +41,14 @@ import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core"; */ const COLUMN = "mx-auto w-full max-w-none px-6"; const DOCK = "mx-auto w-full max-w-[46rem] px-4"; +// The DOCK container's fixed width (46rem) and the plan marginalia's +// geometry (owner, plan-placement round): the bare diagram sits in the +// dock band's RIGHT flank — MARGIN_GAP from the DOCK container's edge and +// the same from the column's right edge. Rendered only when the flank +// holds a real diagram (no slivers); otherwise the top-bar chip alone. +const DOCK_W = 46 * 16; +const MARGIN_GAP = 16; +const MARGIN_MIN_W = 240; // The dock overlay's gradient band (h-24) — part of the reserved tail. const GRADIENT_H = 96; // Fold thresholds (owner, scroll round 1, tuned round 3): pixel distance @@ -212,8 +221,12 @@ export function Chat({ } }, [chatId, setMessages, sendMessage, status]); - // The live plan popup (wave 4): opened by any transcript plan block. + // The live plan popup (wave 4): opened by any transcript plan block, the + // top-bar plan chip, and the dock band's marginalia. const [planOpen, setPlanOpen] = useState(false); + // The session's live plan (drives the top-bar chip; the marginalia + // derives its own from the same transcript). + const planTree = useMemo(() => latestTree(messages), [messages]); // ── The plan verdict gate (docs/probe-teach-split.md §4) ── // The plan has been presented (a tree exists) and the session still sits in @@ -221,8 +234,8 @@ export function Chat({ // Accept / Decline buttons. Acceptance is the explicit plan→teach seam // (a data-plan-accepted part on the message); decline asks for revision. const planVerdict = useMemo( - () => phaseAt3(messages) === "plan" && latestTree(messages) !== null, - [messages], + () => phaseAt3(messages) === "plan" && planTree !== null, + [messages, planTree], ); const sendVerdict = useCallback( (accepted: boolean) => { @@ -487,6 +500,21 @@ export function Chat({
{title}
+ {/* The plan chip (owner, plan-placement round): session-level + structure beside the phase chip — access to the full-DAG popup + at every width; the marginalia is the wide-screen glance view. */} + {planTree && ( + + )}
@@ -630,6 +658,8 @@ export function Chat({ chatId={chatId} settings={settings} onSettingsSaved={setSettings} + plan={} + onOpenPlan={() => setPlanOpen(true)} requiresReveal={requiresReveal} phase={phases.at(-1) ?? "probe"} turns={turns} @@ -669,8 +699,6 @@ export function Chat({ {planOpen && setPlanOpen(false)} />} - {/* The plan's ambient presence: live, zoomed on the current node; click → popup. */} - setPlanOpen(true)} /> ); @@ -710,6 +738,8 @@ function DockOverlay({ settings, onSettingsSaved, onReserve, + plan, + onOpenPlan, }: { pending: ReturnType; /** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */ @@ -727,6 +757,11 @@ function DockOverlay({ /** Reports the dock's settled height (block + gradient) — ChatView's * dockReserve state feeds the transcript's spacer from it. */ onReserve: (h: number) => void; + /** The plan marginalia's content (the bare diagram) — rendered only + * while a question owns the band. Null until a plan exists. */ + plan?: React.ReactNode; + /** Opens the full-DAG popup (the marginalia + chip share it). */ + onOpenPlan: () => void; }) { const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext(); // Teach-phase mount (session reload with a pending check): the dock starts @@ -773,6 +808,24 @@ function DockOverlay({ const revealedRef = useRef(revealed); revealedRef.current = revealed; + // The plan marginalia's flank width (owner, plan-placement round): from + // the DOCK container's right edge to the block's right edge — the bare + // diagram's canvas. Re-measured every render (cheap offsetWidth read): + // window resizes flow through sbInset's listener, and the sidebar toggle + // re-renders the column without any event at all. Below MARGIN_MIN_W the + // margin is no margin — the chip alone carries the plan. + const [flankW, setFlankW] = useState(0); + const flankWRef = useRef(0); + useIsoLayoutEffect(() => { + const block = blockRef.current; + if (!block) return; + const next = Math.round(block.offsetWidth / 2 - DOCK_W / 2 - 2 * MARGIN_GAP); + if (Math.abs(next - flankWRef.current) > 1) { + flankWRef.current = next; + setFlankW(next); + } + }); + // The overlay must not paint over the scroller's scrollbar (owner, scroll // round 2): the scroller reserves symmetric gutters (scrollbar-gutter: // stable both-edges), so inset the overlay's right edge by the gutter — @@ -921,7 +974,7 @@ function DockOverlay({ return (
-
+
@@ -951,6 +1004,26 @@ function DockOverlay({ onSettingsSaved={onSettingsSaved} />
+ {/* The plan marginalia (owner, plan-placement round): the bare + diagram in the band's right flank — boundaless, meet-fitted, part + of the lesson's page rather than a floating widget. Mounted only + while a question owns the band (its reserved height is the canvas); + folding the dock fades + blurs it out of existence, so reading + the transcript in full wipes the margin clean. Click → popup. */} + {pending && plan && flankW >= MARGIN_MIN_W && ( + + )}
); diff --git a/components/plan-block.tsx b/components/plan-block.tsx index e696ea1..2e6f977 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -358,6 +358,7 @@ export function PlanDiagram({ textCapScale, naturalSize, monochrome, + fill, }: { nodes: readonly LayoutNode[]; edges: ReadonlyArray<{ from: string; to: string }>; @@ -379,6 +380,11 @@ export function PlanDiagram({ /** The in-chat block's mode: every node white (fill-none, white stroke * and text, no glow) — statuses never show in the transcript (owner). */ monochrome?: boolean; + /** Meet-fits the diagram inside a fixed box (h-full w-full — the svg's + * default preserveAspectRatio centers + contains the viewBox): the + * dock band's marginalia mode, where the diagram must fit BOTH the + * flank's width and the band's height rather than run width-first. */ + fill?: boolean; }) { const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]); // Pad only the full-extent render — the plan window's override viewBox is @@ -391,7 +397,7 @@ export function PlanDiagram({ return (
- - The plan - - {currentTitle ?? "starting"} - -
- - + ); -} \ No newline at end of file +} diff --git a/tests/plan-window.test.tsx b/tests/plan-window.test.tsx index e610104..30ba1ea 100644 --- a/tests/plan-window.test.tsx +++ b/tests/plan-window.test.tsx @@ -1,13 +1,13 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { planWindowViewBox, PlanWindow } from "../components/plan-window.tsx"; -import { blockLayout, NODE_H, NODE_W, type PopupMessage } from "../components/plan-block.tsx"; +import { PlanMarginalia } from "../components/plan-window.tsx"; +import { blockLayout, type PopupMessage } from "../components/plan-block.tsx"; import type { PlanTree } from "../src/core/contracts.ts"; -// The plan window: the ambient live view — a windowed viewBox centered on -// the current node (auto-pan by derivation, not animation), full plan -// hidden until a tree exists, click → popup. +// The plan marginalia: the bare diagram in the dock band's right flank — +// full plan (padded viewBox, meet-fit), statuses + glow live by derivation, +// nothing until a tree exists. Positioning and fade live in DockOverlay. const CHAIN: PlanTree = { nodes: [ @@ -44,68 +44,34 @@ function transcript(tree: PlanTree, taggedNodeId?: string): PopupMessage[] { ]; } -describe("planWindowViewBox (pure)", () => { - it("centers the window on the current node", () => { - const layout = blockLayout(CHAIN.nodes, CHAIN.edges); - const cy = layout.y.get("b")! + NODE_H / 2; // chain: all x = 0 - const vb = planWindowViewBox(CHAIN, "b").split(" ").map(Number); - assert.equal(vb[3], 180); // 2:1 window - assert.equal(vb[1]! + 90, cy, "vertical center = node b's center"); - // layout narrower than the window → centered on the (single-column) layout - assert.equal(vb[0]! + 180, layout.width / 2); +describe("PlanMarginalia render", () => { + it("renders nothing until a tree exists", () => { + assert.equal(renderToStaticMarkup(), ""); }); - it("falls back to the first node while no check has been tagged — clamped at the layout's top edge", () => { + it("renders the FULL plan (padded full-extent viewBox), not a windowed crop", () => { + const html = renderToStaticMarkup(); + // The viewBox covers the whole layout plus the glow-clearing pad — + // never the retired window's 2:1 crop. const layout = blockLayout(CHAIN.nodes, CHAIN.edges); - const vb = planWindowViewBox(CHAIN, null).split(" ").map(Number); - // Focusing row 0 would center half-off the layout; the clamp pins the - // window to the top edge instead — node a stays fully inside it. - assert.equal(vb[1], 0); - const aTop = layout.y.get("a")!; - assert.ok(aTop >= vb[1] && aTop + NODE_H <= vb[1] + 180, "the first node is inside the window"); + const pad = 24; // MARGIN_PAD: GRAPH_PAD + 16 (glow halo clearance) + const vb = `viewBox="${-pad} ${-pad} ${layout.width + 2 * pad} ${layout.height + 2 * pad}"`; + assert.ok(html.includes(vb), `expected ${vb} in the svg`); + assert.ok(!/viewBox="-?\d+(\.\d+)? -?\d+(\.\d+)? 360 180"/.test(html), "the windowed viewBox is retired"); + // Meet-fit mode: the svg fills the band box (h-full w-full), letting + // preserveAspectRatio contain the diagram in the flank. + assert.ok(html.includes('class="block h-full w-full'), "fill mode classes"); }); - it("clamps along wide rows — the window never strays past an edge node", () => { - const wide: PlanTree = { - nodes: [ - { id: "r1", title: "R1" }, - { id: "r2", title: "R2" }, - { id: "r3", title: "R3" }, - { id: "t", title: "T" }, - ], - edges: [ - { from: "r1", to: "t" }, - { from: "r2", to: "t" }, - { from: "r3", to: "t" }, - ], - version: 1, - }; - const layout = blockLayout(wide.nodes, wide.edges); - assert.ok(layout.width > 360, "fixture: a row wider than the window"); - // Rightmost root: window's right edge must not exceed the layout width. - const vb = planWindowViewBox(wide, "r3").split(" ").map(Number); - assert.ok(vb[0]! + 360 <= layout.width + 0.001, "clamped at the right edge"); - // Leftmost root: window's left edge must not go negative. - const vb2 = planWindowViewBox(wide, "r1").split(" ").map(Number); - assert.ok(vb2[0]! >= -0.001, "clamped at the left edge"); + it("glows the current node and dims the not-yet-visited (live statuses)", () => { + const html = renderToStaticMarkup(); + // The yellow blurred halo marks the tagged check's node… + assert.ok(html.includes("fill-yellow-400/25"), "glow on the current node"); + assert.ok(html.includes('data-testid="plan-node-b" data-status="current"'), "b is current"); + // …and arrows into upcoming nodes carry the dimmed head. + assert.ok( + html.includes("url(#:r0:-dim)") || /url\(#\S*-dim\)/.test(html), + "dimmed arrowhead into the upcoming node", + ); }); }); - -describe("PlanWindow render", () => { - it("hidden while no plan exists", () => { - assert.equal(renderToStaticMarkup( {}} />), ""); - }); - - it("with a plan: renders windowed, glowing current from the latest tagged call; click target present", () => { - const html = renderToStaticMarkup( {}} />); - assert.ok(html.includes('data-testid="plan-window"')); - assert.ok(html.includes('data-status="current"')); - assert.ok(html.includes('data-status="upcoming"')); - // the viewBox is windowed (not the full layout extent) - assert.ok(/viewBox="-?\d+(\.\d+)? -?\d+(\.\d+)? 360 180"/.test(html)); - // no current yet → first node is the fallback focus, header says starting - const html2 = renderToStaticMarkup( {}} />); - assert.ok(html2.includes("starting")); - assert.ok(!html2.includes('data-status="current"'), "nothing current before the first tagged check"); - }); -}); \ No newline at end of file