Plan placement (owner): the plan as marginalia — dock band's right flank + top-bar chip

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).
This commit is contained in:
amosblomqvist
2026-09-14 11:23:28 +03:00
parent 873c153525
commit 93033fac77
4 changed files with 146 additions and 149 deletions
+80 -7
View File
@@ -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({
<header className="flex h-10 items-center gap-3 border-b border-border px-3">
<SidebarToggle />
<div className="min-w-0 flex-1 truncate font-title text-2xl tracking-tight">{title}</div>
{/* 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 && (
<button
type="button"
data-testid="plan-chip"
onClick={() => setPlanOpen(true)}
title="Open the full plan"
className="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/80 px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:border-border hover:text-foreground"
>
<WaypointsIcon className="size-3.5 text-primary" aria-hidden />
Plan
</button>
)}
<PhaseChip phase={phases.at(-1) ?? "probe"} />
</header>
@@ -630,6 +658,8 @@ export function Chat({
chatId={chatId}
settings={settings}
onSettingsSaved={setSettings}
plan={<PlanMarginalia messages={messages} />}
onOpenPlan={() => setPlanOpen(true)}
requiresReveal={requiresReveal}
phase={phases.at(-1) ?? "probe"}
turns={turns}
@@ -669,8 +699,6 @@ export function Chat({
</div>
</div>
{planOpen && <PlanPopup messages={messages} onClose={() => setPlanOpen(false)} />}
{/* The plan's ambient presence: live, zoomed on the current node; click → popup. */}
<PlanWindow messages={messages} onOpen={() => setPlanOpen(true)} />
</JobsContext.Provider>
</ChatActionsContext.Provider>
);
@@ -710,6 +738,8 @@ function DockOverlay({
settings,
onSettingsSaved,
onReserve,
plan,
onOpenPlan,
}: {
pending: ReturnType<typeof findPendingToolCall>;
/** 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 (
<div className="pointer-events-none absolute inset-x-0 bottom-0" style={{ right: sbInset }}>
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
<div ref={blockRef} className="bg-background pb-5">
<div ref={blockRef} className="relative bg-background pb-5">
<div className={DOCK}>
<div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
<div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}>
@@ -951,6 +1004,26 @@ function DockOverlay({
onSettingsSaved={onSettingsSaved}
/>
</div>
{/* 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 && (
<button
type="button"
data-testid="plan-marginalia"
onClick={onOpenPlan}
title="Open the full plan"
style={{ left: `calc(50% + ${DOCK_W / 2 + MARGIN_GAP}px)` }}
className={`absolute bottom-5 right-4 top-0 cursor-pointer transition-[filter,opacity] duration-300 ease-out ${
expanded ? "pointer-events-auto opacity-100 blur-0" : "pointer-events-none opacity-0 blur-md"
}`}
>
{plan}
</button>
)}
</div>
</div>
);