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 (