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:
+80
-7
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user