From 28a78beb97b2d0835b5fb9a3c03f197b81779e3a Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 08:46:07 +0300 Subject: [PATCH 01/15] =?UTF-8?q?Graph-view=20round=201=20(owner):=20full-?= =?UTF-8?q?bleed=20canvas=20=E2=80=94=20no=20padding,=20under=20the=20floa?= =?UTF-8?q?ting=20chrome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home graph view had breathing room on every side (main px-4 py-2, the composer docked below in flow, the header above). Per the owner: - The canvas now owns the whole column — absolute inset-0, from the sidebar to the right edge of the screen, bottom to top, zero padding. - The header floats over the graph (pointer-events-none except the sidebar toggle, so the canvas stays draggable around it). - The new-session blob + stats bar dock OVER the graph at the bottom, no gradient scrims anywhere — the graph runs clean under everything to the very edges. - The renderer itself is untouched: same canvas, same data, same fit logic — only its container grew. The composer keeps chat's DOCK geometry (max-w-[46rem] px-4) so nothing shifts when a session starts. Verified live in headless chromium (1280×800, sidebar open): canvas spans x 288→1280 (sidebar edge → right edge), y 0→800 (bottom to top); the header overlays the top; the dock overlays the bottom with its content ending 16px above the viewport edge. --- components/home.tsx | 38 +++++++++++++++++++++++--------------- package-lock.json | 2 ++ 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/components/home.tsx b/components/home.tsx index a5d1846..189451f 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/package-lock.json b/package-lock.json index d6c41f0..a2771ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,9 @@ "cmdk": "^1.1.1", "d3-force": "^3.0.0", "d3-selection": "^3.0.0", + "d3-shape": "^3.2.0", "d3-zoom": "^3.0.0", + "dagre-d3-es": "7.0.14", "embla-carousel-react": "^8.6.0", "katex": "^0.16.47", "linkedom": "^0.18.13", From 9832e3464fc5360ea8f202411f03a70c18a8f51c Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 08:52:04 +0300 Subject: [PATCH 02/15] Graph-view round 2 (owner): search / settings / import split into their own containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top-right chrome was one shared w-64 panel (search input with the gear inside it, settings expanding beneath, import button below). Per the owner, three separate floating containers, still anchored at the top right: - Search — its own container (icon + input, keeps the no-match note). - Rendering settings — its own COMPONENT (GraphSettingsControl): the gear is its own 44px card button; clicking it expands/collapses the panel, which now drops BELOW the button right-aligned with it. All sliders/Regions/Physics move there verbatim; the open state moved into the component; the gear highlights while open (aria-expanded). - Import notes — its own container (icon + label button). All three sit in one top-right row, equal height (44px + border), 8px apart, same floating-card language (rounded-xl border bg-card/85 shadow backdrop-blur). No renderer/data changes — pure chrome restructure. NOTE (lane): components/graph-view.tsx is graph-stream-owned; done on the owner's direct instruction, flagged for master. Verified live in headless chromium: three containers top-aligned at y=12, heights 46px outer, rightmost edge at the 12px corner inset; gear toggles the 256px panel with every section present. --- components/graph-view.tsx | 661 ++++++++++++++++++++------------------ 1 file changed, 348 insertions(+), 313 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 9ce650e..3a5fe59 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,33 @@ 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. -
- )} -
+ {/* Rendering settings — own component; the gear toggles it. */} + + {/* 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…"} From 0b65838519f282a37b4716a8401adb38a8f52d89 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 08:54:39 +0300 Subject: [PATCH 03/15] =?UTF-8?q?Graph-view=20round=203=20(owner):=20gear?= =?UTF-8?q?=20moved=20to=20the=20far=20right=20=E2=80=94=20its=20panel=20d?= =?UTF-8?q?rops=20at=20the=20right=20edge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Row order is now search → import → gear, so the settings panel (which right-aligns with the gear) opens at the very right edge of the view, covering less of the graph than when it hung off the middle control. Pure reorder, no behavior change. --- components/graph-view.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 3a5fe59..21dc619 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -866,8 +866,6 @@ export function GraphView() {
)}
- {/* Rendering settings — own component; the gear toggles it. */} - {/* Import notes — its own container. */}
+ {/* 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. */} +
)} From ae749fe27d033df02e27a066827ca311f9cf6954 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 09:48:33 +0300 Subject: [PATCH 04/15] Chat top fade round 1 (owner): long soft wash instead of a hard 20px bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header fade was h-5 (20px) from full background opacity — it read as a solid bar with a quick blur on the edge. Now a 96px wash: 65% under the title, holding softly through ~25% at the midpoint, melting to transparent — supports the title, never reads as a bar. --- components/chat.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index 444ce6e..401d2c6 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -485,11 +485,13 @@ export function Chat({
{title}
- {/* Soft top fade (owner): the scroller edge under the header eases - in exactly like the dock gradient at the bottom. */} + {/* Soft top fade (owner, round 1 of the fade rework): a long wash + that holds softly under the title then melts away — no full + opacity anywhere, so it supports the header instead of reading + as a bar. */}
From e285e2bacfd9dd855f0479c1c354b3fb6358c026 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 09:50:53 +0300 Subject: [PATCH 05/15] Chat top fade round 2 (owner): 60% peak at the top, 40px ramp down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 (h-24, 65% hold via 25% mid) read as a faint tail plus an abruptly solid band and stretched far into the chat. What the owner wanted: peak ~60% right under the title, ramping down to transparent a little below — no full opacity anywhere, short reach. Now h-10 from-background/60 to-transparent: a single gentle ramp. --- components/chat.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index 401d2c6..48bfc13 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -485,13 +485,12 @@ export function Chat({
{title}
- {/* Soft top fade (owner, round 1 of the fade rework): a long wash - that holds softly under the title then melts away — no full - opacity anywhere, so it supports the header instead of reading - as a bar. */} + {/* Soft top fade (owner, round 2): peaks at 60% right under the + title and ramps down to transparent within 40px — supports the + header without a bar edge or a long tail into the chat. */}
From 1350f2d094886a9ac10373413438bc8c3f15fc15 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 09:54:02 +0300 Subject: [PATCH 06/15] Chat top fade round 3 (owner): the transcript runs UNDER the title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the 'solid bar' look: the scroller began BELOW the header, so the fade zone sat over plain page background — any opacity there read as a solid bar, no matter the stops. The owner's picture: content goes under the title bar, easing beneath it. - The header is now an overlay (absolute top, z-20) over the scroller, which fills the column to the very top — same floating- chrome pattern as the home graph view. - The wash lives INSIDE the header, behind the title content: 60% background at the very top, ramping down to transparent over 48px — never solid, supports the title, content melts beneath it. - The transcript gets pt-14 (header 40px + its original 16px), so scrolled-to-top content clears the floating title. - Header is pointer-events-none except the sidebar toggle, so the canvas area under the wash stays draggable/selectable. Verified live: scroller top = 0 under the 40px overlay header; wash 48px ramping from 60%; first message at exactly 56px on scroll-to-top; the old below-header edge fade is gone. --- components/chat.tsx | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index 48bfc13..cd561e3 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -477,27 +477,31 @@ export function Chat({ dock overlays the column bottom (outside the scroller); a spacer in the transcript reserves its height. */}
-
- {/* h-10, not py-2: the toggle (a size-9 icon button) renders only - while the sidebar is CLOSED — an auto-height row would collapse - when it disappears and slide the title + chip up. */} -
- -
{title}
- - {/* Soft top fade (owner, round 2): peaks at 60% right under the - title and ramps down to transparent within 40px — supports the - header without a bar edge or a long tail into the chat. */} +
+ {/* The header OVERLAYS the scroller (owner, top-fade round 3): the + transcript runs under the title to the very top, easing beneath + it through the header's own wash — no hard cut below the title. + The wash peaks at 60% at the very top (behind the title) and + ramps down to transparent just under it. h-10, not py-2: the + toggle renders only while the sidebar is CLOSED. */} +
+
+
+ +
+
{title}
+ +
-
+
{messages.length === 0 && !busy && ( Date: Mon, 14 Sep 2026 09:56:45 +0300 Subject: [PATCH 07/15] =?UTF-8?q?Chat=20top=20bar=20round=204=20(owner):?= =?UTF-8?q?=20plain=20block=20=E2=80=94=20revert=20all=20top-gradient=20ro?= =?UTF-8?q?unds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rounds 1–3 chased a soft fade under the title; the owner called the final overlay look wrong too. Back to the simple picture: the top bar is its own block — in flow above the scroller, plain page background, no gradient, no border, no tone change. It reads as a block because the transcript starts below it. (Also reverts round 3's overlay header + pt-14 entirely.) --- components/chat.tsx | 32 ++++++++++++-------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index cd561e3..ccbc7e4 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -477,31 +477,23 @@ export function Chat({ dock overlays the column bottom (outside the scroller); a spacer in the transcript reserves its height. */}
-
- {/* The header OVERLAYS the scroller (owner, top-fade round 3): the - transcript runs under the title to the very top, easing beneath - it through the header's own wash — no hard cut below the title. - The wash peaks at 60% at the very top (behind the title) and - ramps down to transparent just under it. h-10, not py-2: the - toggle renders only while the sidebar is CLOSED. */} -
-
-
-
- -
-
{title}
- -
+
+ {/* The top bar is its own block (owner, top-fade round 4): in flow + above the scroller on the plain page background — no gradient, no + border; it reads as a block simply because the transcript starts + below it. h-10, not py-2: the toggle (a size-9 icon button) + renders only while the sidebar is CLOSED — an auto-height row + would collapse when it disappears and slide the title + chip up. */} +
+ +
{title}
+
-
+
{messages.length === 0 && !busy && ( Date: Mon, 14 Sep 2026 09:57:37 +0300 Subject: [PATCH 08/15] Chat top bar round 5 (owner): bottom outline matching the sidebar separator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit border-b border-border — the same 1px border-border line that separates the left sidebar (border-r on its container), so the top bar now reads as its own block with the app's standard separator. --- components/chat.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index ccbc7e4..b25bccc 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -479,12 +479,12 @@ export function Chat({
{/* The top bar is its own block (owner, top-fade round 4): in flow - above the scroller on the plain page background — no gradient, no - border; it reads as a block simply because the transcript starts - below it. h-10, not py-2: the toggle (a size-9 icon button) + above the scroller on the plain page background — no gradient. Its + bottom outline (border-b border-border) matches the one that + separates the left sidebar — same width, same color (owner, r5). h-10, not py-2: the toggle (a size-9 icon button) renders only while the sidebar is CLOSED — an auto-height row would collapse when it disappears and slide the title + chip up. */} -
+
{title}
From d4b05a0d9024413ee62bd14e064ee4b0cdf5a307 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 10:04:20 +0300 Subject: [PATCH 09/15] Stats bar round 1 (owner): model-control dropdown discipline for the stats popover Same treatment the model/thinking controls got (df4433f): the per-turn stats popover now closes on any pointerdown outside the bar and on Escape. Also centered over the stats bar (left-1/2 -translate-x-1/2) instead of left-aligning with the quiz box, which looked odd. Verified live: popover centered over the bar (centers within 2px); Escape closes; click outside closes; click inside stays open. --- components/stats-bar.tsx | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx index 7716ca7..819416a 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"; @@ -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,6 +66,7 @@ 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). + ref={barRef} className="pointer-events-auto relative flex items-center justify-between gap-3 px-3 pt-1.5 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. */} +
+ + {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. */} @@ -92,58 +148,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 +} From 5dac2a66200a02999d39b94298046b6d91a64f6e Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 10:10:01 +0300 Subject: [PATCH 11/15] Stats bar round 3 (owner): symmetric dropdown closing + vertically centered row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The asymmetry: the stats bar's outside-close ref wrapped the WHOLE bar, which CONTAINS the model control — so clicking model/thinking while stats was open counted as 'inside' and nothing closed, while the reverse direction (model's ref wraps only its own buttons) closed fine. The ref now anchors to the summary wrapper (button + popover), matching the model picker's pattern. Both directions close now. Also centers the stats row vertically in the dock: pt-1.5 -> pt-4, equal to the block's pb-4 — the row sits midway between the composer's bottom and the page bottom. (Font size already matched: both sides compute to 11px JetBrains Mono; the owner chose to keep the color difference — muted stats vs bright labels.) Verified live: model->stats, stats->model, stats->thinking all close the open dropdown and open the new one; row has 16px above (visible text) and 16px below. --- components/stats-bar.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx index 0998fa6..ea235bf 100644 --- a/components/stats-bar.tsx +++ b/components/stats-bar.tsx @@ -61,18 +61,20 @@ export function StatsBar({ return (
{/* 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. */} -
+ — 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. */} +
+ )}
@@ -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 From 38d77204edf0016cb2e619473416d1ce57ef6bdc Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 11:39:57 +0300 Subject: [PATCH 14/15] Marginalia resize round (owner): graceful shrink + phantom-fold mute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from the owner's resize report: - Continuous shrink: the marginalia now mounts whenever a question owns the band and its box width is pure CSS, so it shrinks continuously as the display narrows. Below MARGIN_MIN_W (220, the owner's floor) it FADES over the normal 300ms transition instead of hard-unmounting; widening past the floor fades it back in. No mid-question unmount, no pop, no invisible limbo. - Live flank tracking: a ResizeObserver on the dock block replaces the render-time measurement — the fade floor reacts instantly at any width (window resize, sidebar toggle, scrollbar changes). The old measurement went stale during drag-resizes (nothing re-rendered on macOS until the question rewrapped), so the mount decision popped late and left a sub-floor sliver visible. - Phantom-fold mute: resizing rewraps the question text, the browser re-clamps scrollTop and emits the clamp as a scroll event — the fold machine read the transient position and flickered the box to the bar and back. Scroll events within 400ms of a WIDTH change are now ignored (width-only: stamping the dock's own height morphs self-muted the machine and swallowed the at-bottom auto re-expand, leaving the box stuck collapsed — caught in live verification). Scroll-up/scroll-down fold behavior itself unchanged (verified live: mute-window scroll ignored, post-mute scroll folds, bottom re-expand works). Gates: typecheck, lint, 423/423 tests. --- components/chat.tsx | 65 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index d338368..8606b35 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -44,11 +44,14 @@ 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. +// the same from the column's right edge. The box width is pure CSS, so +// the diagram shrinks CONTINUOUSLY with the flank; MARGIN_MIN_W is only +// the fade floor (owner, resize round) — below it the marginalia fades +// out over the normal transition instead of popping away, and it stays +// mounted while the question owns the band so widening fades it back. const DOCK_W = 46 * 16; const MARGIN_GAP = 16; -const MARGIN_MIN_W = 240; +const MARGIN_MIN_W = 220; // 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 @@ -810,21 +813,40 @@ function DockOverlay({ // 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. + // 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 next = Math.round(block.offsetWidth / 2 - DOCK_W / 2 - 2 * MARGIN_GAP); - if (Math.abs(next - flankWRef.current) > 1) { - flankWRef.current = next; - setFlankW(next); - } - }); + 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: @@ -908,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); @@ -1009,8 +1036,12 @@ function DockOverlay({ 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 && ( + 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 && ( - ); - } - + // 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 (
- + + {/* 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/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( -