diff --git a/app/layout.tsx b/app/layout.tsx index 2a19db7..5719f69 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,7 +3,7 @@ import { JetBrains_Mono, Source_Sans_3 } from "next/font/google"; import "katex/dist/katex.min.css"; import "./globals.css"; import { AppShell, SidebarProvider } from "@/components/sidebar"; -import { ActivityDock, ActivityProvider } from "@/components/activity-dock"; +import { ActivityProvider } from "@/components/activity-dock"; // Chrome = JetBrains Mono, teaching prose = Source Sans 3, titles = CMU // Serif (the bundled lh-cmu @font-face in globals.css — no CDN). @@ -20,13 +20,10 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - - {children} - {/* The reserved bottom-right corner of every view — renders - nothing while no background process runs. */} - - - + + {children} + + ); diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx index 3eab877..f965409 100644 --- a/components/activity-dock.tsx +++ b/components/activity-dock.tsx @@ -1,19 +1,20 @@ "use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIcon, BotIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon, XIcon } from "lucide-react"; +import { ActivityIcon, BotIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import type { CallUsage, JobKind, JobRecord } from "@/src/core"; import { CONTEXT_LIMITS, jobCost } from "@/src/core"; import { formatTokens, formatUsd } from "@/components/usage-format"; /** - * THE ACTIVITY DOCK (wave 4, vision observability): the bottom-right corner - * of EVERY view, one blob per running BACKGROUND process — session sub-agents + * THE BACKGROUND-AGENTS SECTION (owner, sidebar round — the successor of + * the retired bottom-right floating dock): the bottom strip of the LEFT + * SIDEBAR, one blob per running BACKGROUND process — session sub-agents * (research/demo/visual) and global extraction runs. Teaching turns are - * deliberately NOT here: the dock exists to give background processes the - * observability the learner can't otherwise see; an in-flight teaching turn - * is fully visible where it happens (user decision, 2026-09). + * deliberately NOT here: the section exists to give background processes + * the observability the learner can't otherwise see; an in-flight teaching + * turn is fully visible where it happens (user decision, 2026-09). * * Data: ONE poll of GET /api/activity (jobs across ALL chats, title-joined, * oldest-first + extraction runs), folded client-side with core/prices.ts. @@ -23,13 +24,18 @@ import { formatTokens, formatUsd } from "@/components/usage-format"; * Absent data renders "—", never zero (the doctrine). * * Quiet/ambient by design: no entry animations, stable keys, updates land - * in place — polling never flickers. Idle (no jobs, no extractions) renders - * NOTHING. Narrow viewports collapse to a count badge → click opens the - * drawer. Per blob: kind icon, elapsed, cost, context, activity line, and an - * explicit END button (POST /api/activity/abort — the wave-4 backend is - * live): 404/409 mean the process is already gone (refresh, no error); - * real failures degrade to an inline "Abort failed", never a - * dead control). + * in place — polling never flickers. ALWAYS present (owner, sidebar round): + * empty means EMPTY — the header sits alone, nothing hides; it populates and + * GROWS when processes run (max-h + internal scroll so a pile never crowds + * the session list). The sidebar's closed state means the learner opted + * out — no ambient signal while closed (user decision: invisibility until + * a clearer observability story wants building). Per blob: kind icon, + * elapsed, cost, activity line, an explicit END button (POST + * /api/activity/abort — the wave-4 backend is live): 404/409 mean the + * process is already gone (refresh, no error); real failures degrade to + * an inline "Abort failed", never a dead control. Detail (context, model, + * steps, session link) EXPANDS INLINE — the section scrolls, so a + * floating popover would clip at its bounds. */ // ─── Wire shapes (client-safe mirrors of server/observability.ts) ──────────── @@ -181,22 +187,9 @@ export function formatElapsed(ms: number): string { return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`; } -// ─── The dock ───────────────────────────────────────────────────────────────── +// ─── The section (the sidebar's bottom strip) ───────────────────────────────── -/** Narrow viewports (< sm) collapse the dock to a count badge → drawer. */ -function useNarrow(): boolean { - const [narrow, setNarrow] = useState(false); - useEffect(() => { - const mq = window.matchMedia("(max-width: 639px)"); - const apply = () => setNarrow(mq.matches); - apply(); - mq.addEventListener("change", apply); - return () => mq.removeEventListener("change", apply); - }, []); - return narrow; -} - -/** A 1s heartbeat while the dock is on screen — elapsed lines tick. */ +/** A 1s heartbeat while the section is populated — elapsed lines tick. */ function useNow(active: boolean): number { const [now, setNow] = useState(() => Date.now()); useEffect(() => { @@ -216,14 +209,12 @@ const KIND_ICON: Record = { /** Abort states per blob id: pending (fired) / failed (backend absent). */ type AbortState = "pending" | "failed"; -export function ActivityDock({ feed: feedProp }: { feed?: ActivityFeed }) { +export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) { const ctx = useContext(ActivityContext); const feed = feedProp ?? ctx.feed; const refresh = ctx.refresh; const blobs = useMemo(() => toBlobs(feed), [feed]); - const narrow = useNarrow(); - const [drawerOpen, setDrawerOpen] = useState(false); - const [openBlob, setOpenBlob] = useState(null); // detail popover + const [openBlob, setOpenBlob] = useState(null); // detail, inline-expanded const [abortStates, setAbortStates] = useState>({}); const now = useNow(blobs.length > 0); // Refs for stable abort handlers (no re-created closures per tick). @@ -266,63 +257,38 @@ export function ActivityDock({ feed: feedProp }: { feed?: ActivityFeed }) { } }, []); - if (blobs.length === 0) return null; // idle: the dock is invisible - - const count = blobs.length; - // Narrow: a quiet count badge; the drawer opens on click. - if (narrow && !drawerOpen) { - return ( - - ); - } - + // Sidebar-native skin (owner, sidebar round): part of the sidebar's + // surface, not a floating card — the border-t separator matches the + // sidebar's own edge line, the header matches the "Sessions" label, the + // rows match the session rows' hover. Empty means empty: the header sits + // alone (the cost span is population, not decoration). return ( -
-
- - Background activity - - {totalCost === null ? "—" : formatUsd(totalCost)} +
+
+ + Background agents + {blobs.length > 0 && ( + + {totalCost === null ? "—" : formatUsd(totalCost)} - {narrow && ( - )}
- {blobs.map((blob) => ( - setOpenBlob((id) => (id === blob.id ? null : blob.id))} - abortState={abortStates[blob.id]} - onAbort={() => void abort(blob)} - /> - ))} -
+ {blobs.length > 0 && ( +
+ {blobs.map((blob) => ( + setOpenBlob((id) => (id === blob.id ? null : blob.id))} + abortState={abortStates[blob.id]} + onAbort={() => void abort(blob)} + /> + ))} +
+ )} + ); } @@ -356,7 +322,7 @@ function ActivityBlobRow({ return (
+ )} - {/* Soft top fade (owner): the scroller edge under the header eases - in exactly like the dock gradient at the bottom. */} -
@@ -633,6 +661,8 @@ export function Chat({ chatId={chatId} settings={settings} onSettingsSaved={setSettings} + plan={} + onOpenPlan={() => setPlanOpen(true)} requiresReveal={requiresReveal} phase={phases.at(-1) ?? "probe"} turns={turns} @@ -672,8 +702,6 @@ export function Chat({
{planOpen && setPlanOpen(false)} />} - {/* The plan's ambient presence: live, zoomed on the current node; click → popup. */} - setPlanOpen(true)} /> ); @@ -713,6 +741,8 @@ function DockOverlay({ settings, onSettingsSaved, onReserve, + plan, + onOpenPlan, }: { pending: ReturnType; /** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */ @@ -730,6 +760,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 @@ -776,6 +811,43 @@ 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. Tracked LIVE by a ResizeObserver on the block + // (owner, resize round): every geometry change — window resize, sidebar + // toggle, scrollbar appearance, the box's own rewrap — reports the real + // width instantly, so the fade floor never lags behind reality (the old + // render-time measurement went stale during drag-resizes and the mount + // decision popped late). + const [flankW, setFlankW] = useState(0); + const flankWRef = useRef(0); + // Part A of the same round (the resize mute): any block size change + // stamps this — the fold machine ignores scroll events for a beat after, + // because a resize/rewrap re-clamps scrollTop and emits PHANTOM scroll + // events mid-transient (the box flickered to the bar and back). + const geometryAtRef = useRef(0); + useIsoLayoutEffect(() => { + const block = blockRef.current; + if (!block) return; + const measure = () => { + const next = Math.round(block.offsetWidth / 2 - DOCK_W / 2 - 2 * MARGIN_GAP); + // Stamp the mute on WIDTH changes ONLY: column resizes are the + // phantom-scroll source. Height changes are the dock's own doing + // (morphs, question rewraps) — stamping those would self-mute the + // fold machine and swallow the at-bottom auto re-expand right + // after a fold (verified live: the box got stuck collapsed). + if (Math.abs(next - flankWRef.current) > 1) { + flankWRef.current = next; + setFlankW(next); + geometryAtRef.current = Date.now(); + } + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(block); + return () => ro.disconnect(); + }, []); + // 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 — @@ -858,6 +930,11 @@ function DockOverlay({ if (!el) return; const onScroll = () => { if (!pendingRef.current) return; + // Resize mute (owner, resize round): a geometry change re-clamps + // scrollTop and the browser emits the clamp as a scroll event — + // reading the transient position folded the box to the bar for a + // flicker. Real scrolling resumes after the brief mute window. + if (Date.now() - geometryAtRef.current < 400) return; const d = el.scrollHeight - el.clientHeight - el.scrollTop; if (expandedRef.current) { if (d > COLLAPSE_DIST && !expandIntentRef.current) setExpanded(false); @@ -924,7 +1001,7 @@ function DockOverlay({ return (
-
+
@@ -954,6 +1031,32 @@ 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. + Width (owner, resize round): the box shrinks continuously with + the flank; below MARGIN_MIN_W it FADES (never pops away — no + mid-question unmount), and widening past the floor fades it + back in. */} + {pending && plan && ( + + )}
); diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 9ce650e..21dc619 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -194,6 +194,328 @@ function SettingSlider({ ); } +/** + * The rendering-settings gear + its collapsible panel (owner, graph-view + * round 2): its own container at the top right, separate from the search + * box and the import button — clicking the gear expands/collapses the + * panel, which drops below the button, right-aligned with it. + */ +function GraphSettingsControl({ + settings, + changeSettings, +}: { + settings: GraphRenderSettings; + changeSettings: (update: (cur: RenderSettings) => RenderSettings) => void; +}) { + const [open, setOpen] = useState(false); + const regionTuning = settings.regions; + const physicsTuning = settings.physics; + return ( +
+ + {open && ( +
+
+ {/* All sliders: log-scaled 0-100, step 5, so every + DEFAULT sits at 50 (src/core/render-settings.ts). */} + + changeSettings((cur) => ({ ...cur, nodeSize: fromSliderPosition(p, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max) }))} + /> + + changeSettings((cur) => ({ ...cur, edgeWidth: fromSliderPosition(p, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max) }))} + /> + + changeSettings((cur) => ({ ...cur, arrowLength: fromSliderPosition(p, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max) }))} + /> + + changeSettings((cur) => ({ ...cur, labelSize: fromSliderPosition(p, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max) }))} + /> + {/* Displayed units normalize the default (0.5) to "1×". */} + + changeSettings((cur) => ({ ...cur, labelZoom: fromSliderPosition(p, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX) }))} + /> + + {/* Regions: the blurred cluster tints. Sliders + a fixed-set + dropdown for the bitmap resolution (it's a discrete + choice, not a free number). Server-persisted like the + sizes; every range end was sweep-validated on the real + graph. */} +
+ + Regions + + + changeSettings((cur) => ({ + ...cur, + regions: { ...cur.regions, dilation: fromSliderPosition(p, REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max) }, + }))} + /> + + + changeSettings((cur) => ({ + ...cur, + regions: { ...cur.regions, edgeAlpha: fromSliderPosition(p, REGION_EDGE_ALPHA_RANGE.min, REGION_EDGE_ALPHA_RANGE.max) }, + }))} + /> + +
+ {/* Physics: the live sim's forces. Every value feeds the SAME + engine the server settle runs (createGraphSimulation); + changing one rebuilds the sim from the current positions — + drag mid-settle re-anneals, pins survive. Sliders over + sweep-validated ranges (src/core/render-settings.ts): + range ends and all four corners settle cleanly — no NaN, + no explosion, no non-settle. Repulsion is displayed as + the negative it is; cluster pull's far left = off. */} +
+ + Physics + + + + changeSettings((cur) => ({ + ...cur, + physics: { ...cur.physics, depthStrength: fromSliderPosition(p, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max) }, + }))} + /> + + changeSettings((cur) => ({ + ...cur, + physics: { ...cur.physics, depthSpacing: fromSliderPosition(p, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max) }, + }))} + /> + + changeSettings((cur) => ({ + ...cur, + physics: { ...cur.physics, depthBaseRadius: fromSliderPosition(p, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max) }, + }))} + /> + + changeSettings((cur) => ({ + ...cur, + physics: { ...cur.physics, chargeStrength: -fromSliderPosition(p, -CHARGE_RANGE.max, -CHARGE_RANGE.min) }, + }))} + /> + + changeSettings((cur) => ({ + ...cur, + physics: { ...cur.physics, linkStrength: fromSliderPosition(p, LINK_STRENGTH_RANGE.min, LINK_STRENGTH_RANGE.max) }, + }))} + /> + + changeSettings((cur) => ({ + ...cur, + physics: { ...cur.physics, linkDistance: fromSliderPosition(p, LINK_DISTANCE_RANGE.min, LINK_DISTANCE_RANGE.max) }, + }))} + /> + + changeSettings((cur) => ({ + ...cur, + physics: { ...cur.physics, gravity: fromSliderPosition(p, GRAVITY_RANGE.min, GRAVITY_RANGE.max) }, + }))} + /> + + changeSettings((cur) => ({ + ...cur, + physics: { ...cur.physics, clusterPull: fromOffSliderPosition(p, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max) }, + }))} + /> + + changeSettings((cur) => ({ + ...cur, + physics: { ...cur.physics, velocityDecay: fromSliderPosition(p, VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max) }, + }))} + /> +
+
+
+ )} +
+ ); +} + export function GraphView() { const containerRef = useRef(null); const canvasRef = useRef(null); @@ -207,7 +529,6 @@ export function GraphView() { // Search panel state: the query text + the settings section toggle. const [query, setQuery] = useState(""); - const [settingsOpen, setSettingsOpen] = useState(false); // Render settings: SERVER-persisted (data/render-settings.json via // /api/graph/render-settings) — sizes AND the region/physics sections // (user-facing since 2026-09-13; the dev-session era is over). Loaded @@ -522,319 +843,31 @@ export function GraphView() { )} {/* Legend + filters: search box and per-state toggle rows. */} + {/* Top-right controls — three separate floating containers (owner, + graph-view round 2): the search box, the settings gear (its own + collapsible component), and the import button. */} {!isEmpty && ( -
-
- - setQuery(e.target.value)} - placeholder="Search nodes…" - spellCheck={false} - className="h-8 w-full rounded-lg border bg-background pl-8 pr-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" - /> - +
+ {/* Search — its own container (icon + input + no-match note). */} +
+
+ + setQuery(e.target.value)} + placeholder="Search nodes…" + spellCheck={false} + className="h-8 w-full rounded-lg border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" + /> +
+ {snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && ( +
+ No matching nodes. +
+ )}
- {settingsOpen && ( -
- {/* All sliders: log-scaled 0-100, step 5, so every - DEFAULT sits at 50 (src/core/render-settings.ts). */} - - changeSettings((cur) => ({ ...cur, nodeSize: fromSliderPosition(p, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max) }))} - /> - - changeSettings((cur) => ({ ...cur, edgeWidth: fromSliderPosition(p, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max) }))} - /> - - changeSettings((cur) => ({ ...cur, arrowLength: fromSliderPosition(p, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max) }))} - /> - - changeSettings((cur) => ({ ...cur, labelSize: fromSliderPosition(p, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max) }))} - /> - {/* Displayed units normalize the default (0.5) to "1×". */} - - changeSettings((cur) => ({ ...cur, labelZoom: fromSliderPosition(p, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX) }))} - /> - - {/* Regions: the blurred cluster tints. Sliders + a fixed-set - dropdown for the bitmap resolution (it's a discrete - choice, not a free number). Server-persisted like the - sizes; every range end was sweep-validated on the real - graph. */} -
- - Regions - - - changeSettings((cur) => ({ - ...cur, - regions: { ...cur.regions, dilation: fromSliderPosition(p, REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max) }, - }))} - /> - - - changeSettings((cur) => ({ - ...cur, - regions: { ...cur.regions, edgeAlpha: fromSliderPosition(p, REGION_EDGE_ALPHA_RANGE.min, REGION_EDGE_ALPHA_RANGE.max) }, - }))} - /> - -
- {/* Physics: the live sim's forces. Every value feeds the SAME - engine the server settle runs (createGraphSimulation); - changing one rebuilds the sim from the current positions — - drag mid-settle re-anneals, pins survive. Sliders over - sweep-validated ranges (src/core/render-settings.ts): - range ends and all four corners settle cleanly — no NaN, - no explosion, no non-settle. Repulsion is displayed as - the negative it is; cluster pull's far left = off. */} -
- - Physics - - - - changeSettings((cur) => ({ - ...cur, - physics: { ...cur.physics, depthStrength: fromSliderPosition(p, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max) }, - }))} - /> - - changeSettings((cur) => ({ - ...cur, - physics: { ...cur.physics, depthSpacing: fromSliderPosition(p, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max) }, - }))} - /> - - changeSettings((cur) => ({ - ...cur, - physics: { ...cur.physics, depthBaseRadius: fromSliderPosition(p, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max) }, - }))} - /> - - changeSettings((cur) => ({ - ...cur, - physics: { ...cur.physics, chargeStrength: -fromSliderPosition(p, -CHARGE_RANGE.max, -CHARGE_RANGE.min) }, - }))} - /> - - changeSettings((cur) => ({ - ...cur, - physics: { ...cur.physics, linkStrength: fromSliderPosition(p, LINK_STRENGTH_RANGE.min, LINK_STRENGTH_RANGE.max) }, - }))} - /> - - changeSettings((cur) => ({ - ...cur, - physics: { ...cur.physics, linkDistance: fromSliderPosition(p, LINK_DISTANCE_RANGE.min, LINK_DISTANCE_RANGE.max) }, - }))} - /> - - changeSettings((cur) => ({ - ...cur, - physics: { ...cur.physics, gravity: fromSliderPosition(p, GRAVITY_RANGE.min, GRAVITY_RANGE.max) }, - }))} - /> - - changeSettings((cur) => ({ - ...cur, - physics: { ...cur.physics, clusterPull: fromOffSliderPosition(p, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max) }, - }))} - /> - - changeSettings((cur) => ({ - ...cur, - physics: { ...cur.physics, velocityDecay: fromSliderPosition(p, VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max) }, - }))} - /> -
-
- )} - {snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && ( -
- No matching nodes. -
- )} -
+ {/* Import notes — its own container. */} +
fileInputRef.current?.click()} - className="flex h-7 w-full items-center justify-center gap-1.5 rounded-lg border text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50" + className="flex h-8 items-center gap-1.5 rounded-lg px-3 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50" > {importing ? "Importing…" : "Import notes…"}
+ {/* Rendering settings — own component; the gear toggles it. The + gear sits at the far right (owner, round 3) so its panel + drops at the very right edge, over less of the graph. */} +
)} diff --git a/components/home.tsx b/components/home.tsx index a5d1846..a86300e 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -9,9 +9,10 @@ import { StatsBar } from "@/components/stats-bar"; import type { ChatSettings, TurnUsage } from "@/src/core"; /** - * Home — the knowledge graph is the main view (placeholder until the graph - * pipeline lands), with the new-session blob docked below it. Sessions live - * in the toggleable sidebar. + * Home — the knowledge graph is the main view, full-bleed edge to edge + * (owner, graph-view round 1): the canvas owns the whole column, running + * under the header and the new-session dock, both of which float over it + * with no gradient scrims. Sessions live in the toggleable sidebar. */ export function Home() { const router = useRouter(); @@ -48,21 +49,28 @@ export function Home() { }; return ( -
-
- -
- - {/* Graph — the main view (W4: sigma.js render of the derived graph). */} -
+
+ {/* Graph — the main view, full-bleed (owner, graph-view round 1): no + padding on any side; the canvas runs from the sidebar to the right + edge and from the bottom to the top, under everything else. */} +
- {/* New session — same floating blob as the chat composer. */} -
- {/* Matches chat.tsx's DOCK geometry (max-w-[46rem] px-4) so nothing - shifts when a session starts (owner). */} -
+ {/* Header floats over the graph — pointer-events-none except the + toggle, so the canvas stays draggable/hoverable around it. */} +
+
+ +
+
+ + {/* New session — same floating blob as the chat composer, docked + OVER the graph (no gradient: the graph runs clean under it to the + bottom edge). Matches chat.tsx's DOCK geometry (max-w-[46rem] + px-4) so nothing shifts when a session starts (owner). */} +
+
{ 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/components/sidebar.tsx b/components/sidebar.tsx index 3f7f033..1c612ae 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon } from "lucide-react"; +import { ActivitySection } from "@/components/activity-dock"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; @@ -284,8 +285,15 @@ function SessionsSidebar() { )) )}
-
- + + {/* Background agents (owner, sidebar round): the section is + ALWAYS here — empty means empty (the header sits alone); it + populates and grows when processes run. The session list is + flex-1, so this pins to the bottom. Sidebar closed = the + learner opted out (no ambient signal, user decision). */} + +
+ ); } diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx index 7716ca7..749d2f9 100644 --- a/components/stats-bar.tsx +++ b/components/stats-bar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { contextWindow, sessionCost, turnCost, type ChatSettings, type TurnUsage } from "@/src/core"; import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format"; import { ModelControl } from "@/components/model-picker"; @@ -14,8 +14,8 @@ import { ModelControl } from "@/components/model-picker"; * All folds come from core/prices.ts (sessionCost, contextWindow, turnCost) * — no cost math lives here. Absent data renders "—", never zero (the * doctrine): no turns yet → every figure is "—"; turns the price table - * can't price count as unpriced, shown in the popover, never folded into a - * fake $0. The RIGHT side is the model + thinking control (components/ + * can't price count as unpriced, shown in the popover, never folded into + * a fake $0. The RIGHT side is the model + thinking control (components/ * model-picker.tsx): one controller for the conversation agents, backed by * pi-ai's catalog; the buttons show the EFFECTIVE model (the record's * settings, else config's pin), while the left-side figures still fold the @@ -33,6 +33,24 @@ export function StatsBar({ onSettingsSaved: (settings: ChatSettings) => void; }) { const [open, setOpen] = useState(false); + // Same dropdown discipline as the model control (owner, df4433f treatment, + // stats round 1): close on any pointerdown outside the bar or on Escape. + const barRef = useRef(null); + useEffect(() => { + if (!open) return; + const onPointerDown = (e: PointerEvent) => { + if (barRef.current && !barRef.current.contains(e.target as Node)) setOpen(false); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); const ctx = contextWindow({ turns }); const { cost, unpriced } = sessionCost({ turns }); // Honest cost: show dollars only when at least one turn priced; @@ -48,22 +66,81 @@ export function StatsBar({ // (so its transparent gradient area never blocks scrolling) — this row // must re-enable events or every control on it is dead (the model- // control wave caught this live: buttons rendered, clicks never landed). - className="pointer-events-auto relative flex items-center justify-between gap-3 px-3 pt-1.5 text-xs text-muted-foreground" + className="pointer-events-auto relative flex items-center justify-between gap-3 px-3 pt-2 text-xs text-muted-foreground" > - + {/* The summary button + its popover: the popover anchors to this + wrapper, CENTERED over the button itself (owner, stats round 2) + — not over the whole bar row, which made it line up oddly. The + outside-close ref anchors HERE too (owner, round 3): on the whole + bar it wrapped the model control, so clicking model/thinking never + counted as outside — the asymmetry the owner observed. */} +
+ + {open && ( +
+
+ Session stats +
+
+
+ Context (last call) + + {ctx ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : "—"} + +
+
+ Total cost + {costLabel} +
+ {unpriced > 0 && ( +
+ Unpriced turns + {unpriced} +
+ )} +
+ {turns.length > 0 && ( + <> +
+ Turns +
+
    + {turns.map((turn, i) => { + const c = turnCost(turn); + return ( +
  • + + #{i + 1} · {prettyModel(turn.model)} + + + {turn.steps.length} steps + {c === null ? "—" : formatUsd(c)} + +
  • + ); + })} +
+ + )} +
+ )} +
{/* The model + thinking control — one controller for the conversation agents (prober/teacher); the record's `turns` above still folds the ACTUAL models billed, regardless of what the picker says. */} @@ -73,58 +150,6 @@ export function StatsBar({ onSaved={onSettingsSaved} fallbackModelId={lastModel} /> - - {open && ( -
-
- Session stats -
-
-
- Context (last call) - - {ctx ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : "—"} - -
-
- Total cost - {costLabel} -
- {unpriced > 0 && ( -
- Unpriced turns - {unpriced} -
- )} -
- {turns.length > 0 && ( - <> -
- Turns -
-
    - {turns.map((turn, i) => { - const c = turnCost(turn); - return ( -
  • - - #{i + 1} · {prettyModel(turn.model)} - - - {turn.steps.length} steps - {c === null ? "—" : formatUsd(c)} - -
  • - ); - })} -
- - )} -
- )}
); -} \ 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 diff --git a/tests/render-activity-dock.test.tsx b/tests/render-activity-dock.test.tsx index 6ef48eb..e311a1e 100644 --- a/tests/render-activity-dock.test.tsx +++ b/tests/render-activity-dock.test.tsx @@ -1,11 +1,12 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { ActivityDock, formatElapsed, toBlobs, type ActivityFeed } from "../components/activity-dock.tsx"; +import { ActivitySection, formatElapsed, toBlobs, type ActivityFeed } from "../components/activity-dock.tsx"; import { formatTokens, formatUsd } from "../components/usage-format.ts"; -// The dock's pure layer: folds, formatting, and blob order — then the SSR -// render (blobs, "—" doctrine, the abort affordance, the narrow badge). +// The section's pure layer: folds, formatting, and blob order — then the SSR +// render (blobs, "—" doctrine, the abort affordance, the always-present +// header — the sidebar's background-agents strip). const NOW = Date.parse("2026-09-09T12:00:00Z"); @@ -118,21 +119,25 @@ describe("activity dock pure layer", () => { }); }); -describe("activity dock render", () => { - it("renders NOTHING when idle (no jobs, no extractions)", () => { - assert.equal(renderToStaticMarkup(), ""); +describe("activity section render", () => { + it("renders the ALWAYS-PRESENT header when idle — empty means empty, nothing hides", () => { + const html = renderToStaticMarkup(); + assert.match(html, /data-testid="activity-section"/); + assert.match(html, /Background agents/); + assert.doesNotMatch(html, /activity-blob-(job|extraction)/, "no rows while idle"); + assert.doesNotMatch(html, /activity-total-cost/, "the cost span is population, not decoration"); }); it("renders one blob per running process with cost, elapsed, and the abort affordance", () => { const html = renderToStaticMarkup( - , ); - assert.match(html, /data-testid="activity-dock"/); + assert.match(html, /data-testid="activity-section"/); assert.match(html, /data-testid="activity-blob-job"/); assert.match(html, /data-testid="activity-blob-extraction"/); assert.match(html, /Chain-rule demo/); @@ -142,7 +147,7 @@ describe("activity dock render", () => { assert.match(html, /activity-blob-cost[^>]*>—]*>— { steps: 1, }; const html = renderToStaticMarkup( -