Merge feat/ui-polish: owner refinement rounds — chat top bar, stats-bar dropdown discipline, plan as marginalia, dock → sidebar background-agents section, graph-view chrome rounds

This commit is contained in:
amosblomqvist
2026-09-15 07:30:00 +03:00
11 changed files with 738 additions and 665 deletions
+5 -8
View File
@@ -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 })
<html lang="en" className={`dark ${ui.variable} ${prose.variable}`}>
<body className="min-h-dvh antialiased">
<SidebarProvider>
<ActivityProvider>
<AppShell>{children}</AppShell>
{/* The reserved bottom-right corner of every view — renders
nothing while no background process runs. */}
<ActivityDock />
</ActivityProvider>
</SidebarProvider>
<ActivityProvider>
<AppShell>{children}</AppShell>
</ActivityProvider>
</SidebarProvider>
</body>
</html>
);
+53 -87
View File
@@ -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<JobKind, typeof SearchIcon> = {
/** 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<string | null>(null); // detail popover
const [openBlob, setOpenBlob] = useState<string | null>(null); // detail, inline-expanded
const [abortStates, setAbortStates] = useState<Record<string, AbortState>>({});
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 (
<button
type="button"
data-testid="activity-badge"
onClick={() => setDrawerOpen(true)}
title={`${count} background process${count === 1 ? "" : "es"} running`}
aria-label={`${count} background processes running — show activity dock`}
className="fixed bottom-3 right-3 z-30 flex items-center gap-1.5 rounded-full border bg-card/90 px-3 py-1.5 text-xs text-muted-foreground shadow-lg backdrop-blur"
>
<ActivityIcon className="size-3.5 text-primary" />
<span className="tabular-nums">{count}</span>
</button>
);
}
// 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 (
<div
data-testid="activity-dock"
className={cn(
"fixed bottom-3 right-3 z-30 flex w-72 flex-col gap-1 rounded-xl border bg-card/95 p-2 shadow-lg backdrop-blur",
narrow && "max-h-[60dvh] overflow-y-auto",
)}
>
<div className="flex items-center gap-2 px-1 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<ActivityIcon className="size-3.5" />
Background activity
<span className="ml-auto tabular-nums" data-testid="activity-total-cost" title="Total running cost (inferred)">
{totalCost === null ? "—" : formatUsd(totalCost)}
<section data-testid="activity-section" className="border-t border-border px-3 pb-3 pt-2">
<div className="mb-1 flex items-center gap-1.5 text-xxs uppercase tracking-widest text-muted-foreground/70">
<ActivityIcon className="size-3" />
Background agents
{blobs.length > 0 && (
<span className="ml-auto tabular-nums" data-testid="activity-total-cost" title="Total running cost (inferred)">
{totalCost === null ? "—" : formatUsd(totalCost)}
</span>
{narrow && (
<button
type="button"
onClick={() => setDrawerOpen(false)}
aria-label="Hide activity dock"
className="text-muted-foreground transition-colors hover:text-foreground"
>
<XIcon className="size-3.5" />
</button>
)}
</div>
{blobs.map((blob) => (
<ActivityBlobRow
key={blob.id}
blob={blob}
now={now}
detailOpen={openBlob === blob.id}
onToggleDetail={() => setOpenBlob((id) => (id === blob.id ? null : blob.id))}
abortState={abortStates[blob.id]}
onAbort={() => void abort(blob)}
/>
))}
</div>
{blobs.length > 0 && (
<div className="flex max-h-[40dvh] flex-col gap-1 overflow-y-auto">
{blobs.map((blob) => (
<ActivityBlobRow
key={blob.id}
blob={blob}
now={now}
detailOpen={openBlob === blob.id}
onToggleDetail={() => setOpenBlob((id) => (id === blob.id ? null : blob.id))}
abortState={abortStates[blob.id]}
onAbort={() => void abort(blob)}
/>
))}
</div>
)}
</section>
);
}
@@ -356,7 +322,7 @@ function ActivityBlobRow({
return (
<div
data-testid={`activity-blob-${blob.process}`}
className="relative rounded-lg bg-background/40 px-2 py-1.5 text-xs transition-colors hover:bg-background/70"
className="relative rounded-lg px-2 py-1.5 text-xs transition-colors hover:bg-accent"
>
<button
type="button"
@@ -398,7 +364,7 @@ function ActivityBlobRow({
{detailOpen && (
<div
data-testid="activity-blob-detail"
className="absolute bottom-full right-0 z-40 mb-1.5 w-72 rounded-xl border bg-card p-3 text-xs shadow-xl"
className="mt-1 flex flex-col gap-1 rounded-lg bg-accent/40 p-2 text-muted-foreground"
>
<div className="mb-1.5 flex items-center gap-2">
<Icon className="size-3.5 text-muted-foreground" />
+120 -17
View File
@@ -23,7 +23,8 @@ import { JobsContext } from "@/components/jobs-context";
import { StatsBar } from "@/components/stats-bar";
import type { ChatSettings } from "@/src/core";
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
import { PlanWindow } from "@/components/plan-window";
import { PlanMarginalia } from "@/components/plan-window";
import { WaypointsIcon } from "lucide-react";
import type { PlanBlockData } from "@/src/core";
import { QuestionDock } from "@/components/question-dock";
import { SidebarToggle } from "@/components/sidebar";
@@ -40,6 +41,17 @@ 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. 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 = 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
@@ -212,8 +224,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 +237,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) => {
@@ -478,19 +494,31 @@ export function Chat({
the transcript reserves its height. */}
<div className="flex h-dvh">
<div className="flex min-w-0 flex-1 flex-col">
{/* 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. */}
<header className="relative flex h-10 items-center gap-3 px-3">
{/* The top bar is its own block (owner, top-fade round 4): in flow
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. */}
<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"} />
{/* Soft top fade (owner): the scroller edge under the header eases
in exactly like the dock gradient at the bottom. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-full z-10 h-5 bg-gradient-to-b from-background to-transparent"
/>
</header>
<div className="relative flex min-h-0 flex-1">
@@ -633,6 +661,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}
@@ -672,8 +702,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>
);
@@ -713,6 +741,8 @@ function DockOverlay({
settings,
onSettingsSaved,
onReserve,
plan,
onOpenPlan,
}: {
pending: ReturnType<typeof findPendingToolCall>;
/** 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 (
<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-4">
<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"}>
@@ -954,6 +1031,32 @@ 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.
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 && (
<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 && flankW >= MARGIN_MIN_W
? "pointer-events-auto opacity-100 blur-0"
: "pointer-events-none opacity-0 blur-md"
}`}
>
{plan}
</button>
)}
</div>
</div>
);
+350 -313
View File
@@ -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 (
<div className="relative">
<button
type="button"
title="Rendering settings"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
className={`flex size-11 items-center justify-center rounded-xl border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${
open ? "text-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
<SettingsIcon className="size-4" />
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-64 rounded-xl border bg-card/85 p-2.5 shadow-lg backdrop-blur">
<div className="flex flex-col gap-2">
{/* All sliders: log-scaled 0-100, step 5, so every
DEFAULT sits at 50 (src/core/render-settings.ts). */}
<SettingSlider
label="Node size"
min={0}
max={100}
step={5}
value={toSliderPosition(settings.nodeSize, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max)}
display={sizeDisplay(settings.nodeSize)}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, nodeSize: fromSliderPosition(p, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max) }))}
/>
<SettingSlider
label="Edge width"
min={0}
max={100}
step={5}
value={toSliderPosition(settings.edgeWidth, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max)}
display={sizeDisplay(settings.edgeWidth)}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, edgeWidth: fromSliderPosition(p, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max) }))}
/>
<SettingSlider
label="Arrowhead size"
min={0}
max={100}
step={5}
value={toSliderPosition(settings.arrowLength, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max)}
display={sizeDisplay(settings.arrowLength)}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, arrowLength: fromSliderPosition(p, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max) }))}
/>
<SettingSlider
label="Label size"
min={0}
max={100}
step={5}
value={toSliderPosition(settings.labelSize, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max)}
display={sizeDisplay(settings.labelSize)}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, labelSize: fromSliderPosition(p, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max) }))}
/>
{/* Displayed units normalize the default (0.5) to "1×". */}
<SettingSlider
label="Labels until zoom"
min={0}
max={100}
step={5}
value={toSliderPosition(settings.labelZoom, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX)}
display={labelZoomDisplay(settings.labelZoom)}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, labelZoom: fromSliderPosition(p, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX) }))}
/>
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
<span>Cluster labels</span>
<input
type="checkbox"
checked={settings.clusterLabels}
onChange={(e) => changeSettings((cur) => ({ ...cur, clusterLabels: e.target.checked }))}
className="accent-primary"
/>
</label>
{/* 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. */}
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Regions
</span>
<SettingSlider
label="Dilation"
min={0}
max={100}
step={5}
value={toSliderPosition(regionTuning.dilation, REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max)}
display={sizeDisplay(regionTuning.dilation)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
regions: { ...cur.regions, dilation: fromSliderPosition(p, REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max) },
}))}
/>
<label className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
<span>Blur</span>
<select
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
value={regionTuning.blur}
onChange={(e) =>
changeSettings((cur) => ({
...cur,
regions: { ...cur.regions, blur: Number(e.target.value) },
}))
}
>
{REGION_BLUR_OPTIONS.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</label>
<SettingSlider
label="Edge alpha"
min={0}
max={100}
step={5}
value={toSliderPosition(regionTuning.edgeAlpha, REGION_EDGE_ALPHA_RANGE.min, REGION_EDGE_ALPHA_RANGE.max)}
display={sizeDisplay(regionTuning.edgeAlpha)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
regions: { ...cur.regions, edgeAlpha: fromSliderPosition(p, REGION_EDGE_ALPHA_RANGE.min, REGION_EDGE_ALPHA_RANGE.max) },
}))}
/>
<label className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
<span>Bitmap resolution</span>
<select
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
value={regionTuning.bitmap}
onChange={(e) =>
changeSettings((cur) => ({
...cur,
regions: { ...cur.regions, bitmap: Number(e.target.value) },
}))
}
>
{REGION_BITMAP_OPTIONS.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</label>
</div>
{/* 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. */}
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Physics
</span>
<label className="flex items-center justify-between gap-2 text-xs">
Depth anchor
<select
className="rounded border bg-background px-1 py-0.5 text-xs"
value={physicsTuning.depthMode}
onChange={(e) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, depthMode: e.target.value as GraphRenderSettings["physics"]["depthMode"] },
}))
}
>
<option value="off">Off</option>
<option value="vertical">Vertical (up)</option>
<option value="radial">Radial (outward)</option>
</select>
</label>
<SettingSlider
label="Depth strength"
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.depthStrength, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max)}
display={sizeDisplay(physicsTuning.depthStrength)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, depthStrength: fromSliderPosition(p, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max) },
}))}
/>
<SettingSlider
label="Depth spacing"
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.depthSpacing, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max)}
display={sizeDisplay(physicsTuning.depthSpacing)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, depthSpacing: fromSliderPosition(p, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max) },
}))}
/>
<SettingSlider
label="Base ring radius"
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.depthBaseRadius, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max)}
display={sizeDisplay(physicsTuning.depthBaseRadius)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, depthBaseRadius: fromSliderPosition(p, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max) },
}))}
/>
<SettingSlider
label="Node repulsion"
min={0}
max={100}
step={5}
value={toSliderPosition(-physicsTuning.chargeStrength, -CHARGE_RANGE.max, -CHARGE_RANGE.min)}
display={`-${Math.round(-physicsTuning.chargeStrength)}`}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, chargeStrength: -fromSliderPosition(p, -CHARGE_RANGE.max, -CHARGE_RANGE.min) },
}))}
/>
<SettingSlider
label="Edge attraction"
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.linkStrength, LINK_STRENGTH_RANGE.min, LINK_STRENGTH_RANGE.max)}
display={sizeDisplay(physicsTuning.linkStrength)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, linkStrength: fromSliderPosition(p, LINK_STRENGTH_RANGE.min, LINK_STRENGTH_RANGE.max) },
}))}
/>
<SettingSlider
label="Edge length"
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.linkDistance, LINK_DISTANCE_RANGE.min, LINK_DISTANCE_RANGE.max)}
display={sizeDisplay(physicsTuning.linkDistance)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, linkDistance: fromSliderPosition(p, LINK_DISTANCE_RANGE.min, LINK_DISTANCE_RANGE.max) },
}))}
/>
<SettingSlider
label="Central pull"
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.gravity, GRAVITY_RANGE.min, GRAVITY_RANGE.max)}
display={sizeDisplay(physicsTuning.gravity)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, gravity: fromSliderPosition(p, GRAVITY_RANGE.min, GRAVITY_RANGE.max) },
}))}
/>
<SettingSlider
label="Cluster pull"
min={0}
max={100}
step={5}
value={toOffSliderPosition(physicsTuning.clusterPull, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max)}
display={physicsTuning.clusterPull <= 0 ? "off" : sizeDisplay(physicsTuning.clusterPull)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, clusterPull: fromOffSliderPosition(p, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max) },
}))}
/>
<SettingSlider
label="Friction"
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.velocityDecay, VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max)}
display={sizeDisplay(physicsTuning.velocityDecay)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, velocityDecay: fromSliderPosition(p, VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max) },
}))}
/>
</div>
</div>
</div>
)}
</div>
);
}
export function GraphView() {
const containerRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(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 && (
<div className="absolute right-3 top-3 z-10 w-64 rounded-xl border bg-card/85 p-2.5 shadow-lg backdrop-blur">
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(e) => 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"
/>
<button
type="button"
title="Rendering settings"
onClick={() => setSettingsOpen((o) => !o)}
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<SettingsIcon className="size-3.5" />
</button>
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
{/* Search — its own container (icon + input + no-match note). */}
<div className="w-56 rounded-xl border bg-card/85 p-1.5 shadow-lg backdrop-blur">
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(e) => 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"
/>
</div>
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
<div className="px-1.5 pt-1 text-[11px] text-muted-foreground">
No matching nodes.
</div>
)}
</div>
{settingsOpen && (
<div className="mt-1.5 flex flex-col gap-2 border-t pt-1.5">
{/* All sliders: log-scaled 0-100, step 5, so every
DEFAULT sits at 50 (src/core/render-settings.ts). */}
<SettingSlider
label="Node size"
min={0}
max={100}
step={5}
value={toSliderPosition(settings.nodeSize, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max)}
display={sizeDisplay(settings.nodeSize)}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, nodeSize: fromSliderPosition(p, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max) }))}
/>
<SettingSlider
label="Edge width"
min={0}
max={100}
step={5}
value={toSliderPosition(settings.edgeWidth, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max)}
display={sizeDisplay(settings.edgeWidth)}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, edgeWidth: fromSliderPosition(p, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max) }))}
/>
<SettingSlider
label="Arrowhead size"
min={0}
max={100}
step={5}
value={toSliderPosition(settings.arrowLength, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max)}
display={sizeDisplay(settings.arrowLength)}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, arrowLength: fromSliderPosition(p, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max) }))}
/>
<SettingSlider
label="Label size"
min={0}
max={100}
step={5}
value={toSliderPosition(settings.labelSize, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max)}
display={sizeDisplay(settings.labelSize)}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, labelSize: fromSliderPosition(p, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max) }))}
/>
{/* Displayed units normalize the default (0.5) to "1×". */}
<SettingSlider
label="Labels until zoom"
min={0}
max={100}
step={5}
value={toSliderPosition(settings.labelZoom, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX)}
display={labelZoomDisplay(settings.labelZoom)}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, labelZoom: fromSliderPosition(p, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX) }))}
/>
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
<span>Cluster labels</span>
<input
type="checkbox"
checked={settings.clusterLabels}
onChange={(e) => changeSettings((cur) => ({ ...cur, clusterLabels: e.target.checked }))}
className="accent-primary"
/>
</label>
{/* 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. */}
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Regions
</span>
<SettingSlider
label="Dilation"
min={0}
max={100}
step={5}
value={toSliderPosition(regionTuning.dilation, REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max)}
display={sizeDisplay(regionTuning.dilation)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
regions: { ...cur.regions, dilation: fromSliderPosition(p, REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max) },
}))}
/>
<label className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
<span>Blur</span>
<select
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
value={regionTuning.blur}
onChange={(e) =>
changeSettings((cur) => ({
...cur,
regions: { ...cur.regions, blur: Number(e.target.value) },
}))
}
>
{REGION_BLUR_OPTIONS.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</label>
<SettingSlider
label="Edge alpha"
min={0}
max={100}
step={5}
value={toSliderPosition(regionTuning.edgeAlpha, REGION_EDGE_ALPHA_RANGE.min, REGION_EDGE_ALPHA_RANGE.max)}
display={sizeDisplay(regionTuning.edgeAlpha)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
regions: { ...cur.regions, edgeAlpha: fromSliderPosition(p, REGION_EDGE_ALPHA_RANGE.min, REGION_EDGE_ALPHA_RANGE.max) },
}))}
/>
<label className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
<span>Bitmap resolution</span>
<select
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
value={regionTuning.bitmap}
onChange={(e) =>
changeSettings((cur) => ({
...cur,
regions: { ...cur.regions, bitmap: Number(e.target.value) },
}))
}
>
{REGION_BITMAP_OPTIONS.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</label>
</div>
{/* 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. */}
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Physics
</span>
<label className="flex items-center justify-between gap-2 text-xs">
Depth anchor
<select
className="rounded border bg-background px-1 py-0.5 text-xs"
value={physicsTuning.depthMode}
onChange={(e) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, depthMode: e.target.value as GraphRenderSettings["physics"]["depthMode"] },
}))
}
>
<option value="off">Off</option>
<option value="vertical">Vertical (up)</option>
<option value="radial">Radial (outward)</option>
</select>
</label>
<SettingSlider
label="Depth strength"
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.depthStrength, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max)}
display={sizeDisplay(physicsTuning.depthStrength)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, depthStrength: fromSliderPosition(p, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max) },
}))}
/>
<SettingSlider
label="Depth spacing"
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.depthSpacing, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max)}
display={sizeDisplay(physicsTuning.depthSpacing)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, depthSpacing: fromSliderPosition(p, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max) },
}))}
/>
<SettingSlider
label="Base ring radius"
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.depthBaseRadius, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max)}
display={sizeDisplay(physicsTuning.depthBaseRadius)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, depthBaseRadius: fromSliderPosition(p, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max) },
}))}
/>
<SettingSlider
label="Node repulsion"
min={0}
max={100}
step={5}
value={toSliderPosition(-physicsTuning.chargeStrength, -CHARGE_RANGE.max, -CHARGE_RANGE.min)}
display={`-${Math.round(-physicsTuning.chargeStrength)}`}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, chargeStrength: -fromSliderPosition(p, -CHARGE_RANGE.max, -CHARGE_RANGE.min) },
}))}
/>
<SettingSlider
label="Edge attraction"
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.linkStrength, LINK_STRENGTH_RANGE.min, LINK_STRENGTH_RANGE.max)}
display={sizeDisplay(physicsTuning.linkStrength)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, linkStrength: fromSliderPosition(p, LINK_STRENGTH_RANGE.min, LINK_STRENGTH_RANGE.max) },
}))}
/>
<SettingSlider
label="Edge length"
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.linkDistance, LINK_DISTANCE_RANGE.min, LINK_DISTANCE_RANGE.max)}
display={sizeDisplay(physicsTuning.linkDistance)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, linkDistance: fromSliderPosition(p, LINK_DISTANCE_RANGE.min, LINK_DISTANCE_RANGE.max) },
}))}
/>
<SettingSlider
label="Central pull"
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.gravity, GRAVITY_RANGE.min, GRAVITY_RANGE.max)}
display={sizeDisplay(physicsTuning.gravity)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, gravity: fromSliderPosition(p, GRAVITY_RANGE.min, GRAVITY_RANGE.max) },
}))}
/>
<SettingSlider
label="Cluster pull"
min={0}
max={100}
step={5}
value={toOffSliderPosition(physicsTuning.clusterPull, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max)}
display={physicsTuning.clusterPull <= 0 ? "off" : sizeDisplay(physicsTuning.clusterPull)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, clusterPull: fromOffSliderPosition(p, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max) },
}))}
/>
<SettingSlider
label="Friction"
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.velocityDecay, VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max)}
display={sizeDisplay(physicsTuning.velocityDecay)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, velocityDecay: fromSliderPosition(p, VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max) },
}))}
/>
</div>
</div>
)}
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
<div className="mt-1.5 border-t px-1.5 pt-1.5 text-[11px] text-muted-foreground">
No matching nodes.
</div>
)}
<div className="mt-1.5 border-t pt-1.5">
{/* Import notes — its own container. */}
<div className="rounded-xl border bg-card/85 p-1.5 shadow-lg backdrop-blur">
<input
ref={fileInputRef}
type="file"
@@ -850,12 +883,16 @@ export function GraphView() {
type="button"
disabled={importing}
onClick={() => 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"
>
<UploadIcon className="size-3" />
{importing ? "Importing…" : "Import notes…"}
</button>
</div>
{/* 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. */}
<GraphSettingsControl settings={settings} changeSettings={changeSettings} />
</div>
)}
+23 -15
View File
@@ -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 (
<div className="flex h-dvh flex-col">
<header className="flex items-center gap-2 px-3 py-2">
<SidebarToggle />
</header>
{/* Graph — the main view (W4: sigma.js render of the derived graph). */}
<main className="min-h-0 flex-1 px-4 py-2">
<div className="relative h-dvh">
{/* 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. */}
<main className="absolute inset-0">
<GraphView />
</main>
{/* New session — same floating blob as the chat composer. */}
<div className="pb-4">
{/* Matches chat.tsx's DOCK geometry (max-w-[46rem] px-4) so nothing
shifts when a session starts (owner). */}
<div className="mx-auto w-full max-w-[46rem] px-4">
{/* Header floats over the graph — pointer-events-none except the
toggle, so the canvas stays draggable/hoverable around it. */}
<header className="pointer-events-none absolute inset-x-0 top-0 flex items-center gap-2 px-3 py-2">
<div className="pointer-events-auto">
<SidebarToggle />
</div>
</header>
{/* 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). */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 pb-5">
<div className="pointer-events-auto mx-auto w-full max-w-[46rem] px-4">
<form
className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg"
onSubmit={(e) => {
+7 -1
View File
@@ -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 (
<svg
viewBox={viewBoxOverride ?? `${-pad} ${-pad} ${vbW} ${vbH}`}
className={`block h-auto${naturalSize ? "" : " w-full"}${textCapScale ? " mx-auto" : ""}`}
className={`block ${fill ? "h-full w-full" : `h-auto${naturalSize ? "" : " w-full"}`}${textCapScale ? " mx-auto" : ""}`}
style={
textCapScale
? { maxWidth: `${Math.round(vbW * textCapScale * 100) / 100}px` }
+30 -78
View File
@@ -1,102 +1,54 @@
"use client";
import { useId, useMemo } from "react";
import { WaypointsIcon } from "lucide-react";
import type { PlanTree } from "@/src/core";
import { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
import {
PlanDiagram,
blockLayout,
GRAPH_PAD,
latestTree,
NODE_H,
NODE_W,
TEXT_CAP_SCALE,
type PopupMessage,
} from "@/components/plan-block";
/**
* THE PLAN WINDOW (docs/probe-teach-split.md §6): the plan's persistent,
* ambient presence — the successor of the injected plan blocks. Always on
* once a tree exists, it shows a ZOOMED-IN window on the CURRENT node and
* moves by itself as the lesson advances (auto-pan: the viewBox re-centers
* on the latest tagged check — no animation machinery, the window just
* follows the derived position). Click opens the full-DAG popup.
* THE PLAN MARGINALIA: the plan's ambient presence in the session view —
* the successor of the old fixed corner card (the plan window, retired
* with the dock-band placement round). NO container: the bare diagram,
* meet-fitted into the dock band's right flank — the wrapper (DockOverlay)
* positions and fades it — so it reads as marginalia on the lesson's
* page, not a floating widget. Live by construction: statuses derive from
* the transcript on every render (derivePlanStatuses — calls, not
* results), so the glow moves the moment a tagged check streams in.
*
* Live by construction: statuses derive from the transcript on every
* render (derivePlanStatuses — calls, not results), so the window moves
* the moment a tagged check call streams in or a record adoption lands.
* Hidden on narrow viewports (the dock owns that corner's collapse
* pattern); the learner's visual polish pass (colors, motion) comes later.
* Visibile only while a question owns the dock band (the reserved tail's
* height is the diagram's canvas — quiz open, tall band, full plan);
* folding the dock fades + blurs the whole thing out of existence. The
* top-bar plan chip carries access at every width; the popup is the study
* view. The viewBox pad clears the current node's blurred yellow halo
* (GLOW_REACH) at the extent edges.
*/
/** Window dimensions in SVG units — 2:1, ~2¼ node widths: the current
* node plus its immediate neighborhood. Aspect-matched to the card. */
const VIEW_W = 360;
const VIEW_H = 180;
const MARGIN_PAD = GRAPH_PAD + 16;
/**
* The windowed viewBox, centered on the current node — pure (exported for
* tests). Falls back to the FIRST node (authoring order — the lesson's
* start) while no check has been tagged. A layout smaller than the window
* centers itself; a larger one clamps so the window never strays into
* empty space beyond an edge node.
*/
export function planWindowViewBox(tree: PlanTree, current: string | null): string {
const layout = blockLayout(tree.nodes, tree.edges);
const focusId =
current && layout.x.has(current)
? current
: (tree.nodes[0]?.id ?? null);
const cx =
focusId !== null
? (layout.x.get(focusId) ?? 0) + NODE_W / 2
: Math.max(layout.width, 1) / 2;
const cy =
focusId !== null
? (layout.y.get(focusId) ?? 0) + (layout.nodeH.get(focusId) ?? NODE_H) / 2
: Math.max(layout.height, 1) / 2;
const clamp = (c: number, extent: number, view: number) =>
extent <= view ? extent / 2 : Math.min(Math.max(c, view / 2), extent - view / 2);
const x = clamp(cx, layout.width, VIEW_W) - VIEW_W / 2;
const y = clamp(cy, layout.height, VIEW_H) - VIEW_H / 2;
return `${x} ${y} ${VIEW_W} ${VIEW_H}`;
}
export function PlanWindow({ messages, onOpen }: { messages: readonly PopupMessage[]; onOpen: () => void }) {
export function PlanMarginalia({ messages }: { messages: readonly PopupMessage[] }) {
const markerId = useId().replace(/[^a-zA-Z0-9-]/g, "");
const tree = useMemo(() => latestTree(messages), [messages]);
const statuses = useMemo(
() => (tree ? derivePlanStatuses(tree, messages as Parameters<typeof derivePlanStatuses>[1]) : null),
[tree, messages],
);
if (!tree || tree.nodes.length === 0) return null; // no plan yet: no window
if (!tree || tree.nodes.length === 0) return null; // no plan yet: nothing in the margin
const statusOf = (id: string) => (statuses ? derivedNodeStatus(statuses, id) : "upcoming");
const currentTitle = statuses?.current
? (tree.nodes.find((n) => n.id === statuses.current)?.title ?? null)
: null;
return (
<button
type="button"
data-testid="plan-window"
onClick={onOpen}
title="Open the full plan"
className="fixed bottom-3 left-3 z-30 hidden w-80 flex-col gap-1 rounded-xl border bg-card/95 p-2 text-left shadow-lg backdrop-blur sm:flex"
>
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<WaypointsIcon className="size-3.5 text-primary" />
The plan
<span className="ml-auto min-w-0 truncate font-normal normal-case" title={currentTitle ?? undefined}>
{currentTitle ?? "starting"}
</span>
</div>
<PlanDiagram
nodes={tree.nodes}
edges={tree.edges}
statusOf={statusOf}
glowId={statuses?.current ?? null}
markerId={markerId}
viewBoxOverride={planWindowViewBox(tree, statuses?.current ?? null)}
/>
</button>
<PlanDiagram
nodes={tree.nodes}
edges={tree.edges}
statusOf={statusOf}
glowId={statuses?.current ?? null}
markerId={markerId}
pad={MARGIN_PAD}
textCapScale={TEXT_CAP_SCALE}
fill
/>
);
}
}
+10 -2
View File
@@ -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() {
))
)}
</div>
</div>
</aside>
{/* 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). */}
<ActivitySection />
</div>
</aside>
);
}
+96 -71
View File
@@ -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<HTMLDivElement>(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"
>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
title="Session stats — per-turn detail"
data-testid="stats-summary"
className="flex items-center gap-1.5 tabular-nums transition-colors hover:text-foreground"
>
<span data-testid="stats-context">
{ctx ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : "—"}
</span>
<span aria-hidden>·</span>
<span data-testid="stats-cost">{costLabel}</span>
</button>
{/* 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. */}
<div ref={barRef} className="relative">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
title="Session stats — per-turn detail"
data-testid="stats-summary"
className="flex items-center gap-1.5 tabular-nums transition-colors hover:text-foreground"
>
<span data-testid="stats-context">
{ctx ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : "—"}
</span>
<span aria-hidden>·</span>
<span data-testid="stats-cost">{costLabel}</span>
</button>
{open && (
<div
data-testid="stats-detail"
className="absolute bottom-full left-1/2 z-40 mb-1.5 w-72 -translate-x-1/2 rounded-xl border bg-card p-3 text-xs shadow-xl"
>
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Session stats
</div>
<div className="flex flex-col gap-1 text-muted-foreground">
<div className="flex justify-between gap-2">
<span>Context (last call)</span>
<span className="tabular-nums text-card-foreground">
{ctx ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : "—"}
</span>
</div>
<div className="flex justify-between gap-2">
<span>Total cost</span>
<span className="tabular-nums text-card-foreground">{costLabel}</span>
</div>
{unpriced > 0 && (
<div className="flex justify-between gap-2">
<span>Unpriced turns</span>
<span className="tabular-nums text-card-foreground">{unpriced}</span>
</div>
)}
</div>
{turns.length > 0 && (
<>
<div className="mb-1 mt-2.5 border-t pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Turns
</div>
<ul className="flex max-h-56 flex-col gap-1 overflow-y-auto">
{turns.map((turn, i) => {
const c = turnCost(turn);
return (
<li key={`${turn.messageId}-${i}`} className="flex items-center justify-between gap-2">
<span className="truncate text-muted-foreground">
#{i + 1} · {prettyModel(turn.model)}
</span>
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
<span className="text-muted-foreground">{turn.steps.length} steps</span>
<span>{c === null ? "—" : formatUsd(c)}</span>
</span>
</li>
);
})}
</ul>
</>
)}
</div>
)}
</div>
{/* 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 && (
<div
data-testid="stats-detail"
className="absolute bottom-full left-0 z-40 mb-1.5 w-72 rounded-xl border bg-card p-3 text-xs shadow-xl"
>
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Session stats
</div>
<div className="flex flex-col gap-1 text-muted-foreground">
<div className="flex justify-between gap-2">
<span>Context (last call)</span>
<span className="tabular-nums text-card-foreground">
{ctx ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : "—"}
</span>
</div>
<div className="flex justify-between gap-2">
<span>Total cost</span>
<span className="tabular-nums text-card-foreground">{costLabel}</span>
</div>
{unpriced > 0 && (
<div className="flex justify-between gap-2">
<span>Unpriced turns</span>
<span className="tabular-nums text-card-foreground">{unpriced}</span>
</div>
)}
</div>
{turns.length > 0 && (
<>
<div className="mb-1 mt-2.5 border-t pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Turns
</div>
<ul className="flex max-h-56 flex-col gap-1 overflow-y-auto">
{turns.map((turn, i) => {
const c = turnCost(turn);
return (
<li key={`${turn.messageId}-${i}`} className="flex items-center justify-between gap-2">
<span className="truncate text-muted-foreground">
#{i + 1} · {prettyModel(turn.model)}
</span>
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
<span className="text-muted-foreground">{turn.steps.length} steps</span>
<span>{c === null ? "—" : formatUsd(c)}</span>
</span>
</li>
);
})}
</ul>
</>
)}
</div>
)}
</div>
);
}
}
+29 -63
View File
@@ -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(<PlanMarginalia messages={[]} />), "");
});
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(<PlanMarginalia messages={transcript(CHAIN)} />);
// 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(<PlanMarginalia messages={transcript(CHAIN, "b")} />);
// 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(<PlanWindow messages={[]} onOpen={() => {}} />), "");
});
it("with a plan: renders windowed, glowing current from the latest tagged call; click target present", () => {
const html = renderToStaticMarkup(<PlanWindow messages={transcript(CHAIN, "b")} onOpen={() => {}} />);
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(<PlanWindow messages={transcript(CHAIN)} onOpen={() => {}} />);
assert.ok(html2.includes("starting"));
assert.ok(!html2.includes('data-status="current"'), "nothing current before the first tagged check");
});
});
+15 -10
View File
@@ -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(<ActivityDock feed={{ jobs: [], extractions: [] }} />), "");
describe("activity section render", () => {
it("renders the ALWAYS-PRESENT header when idle — empty means empty, nothing hides", () => {
const html = renderToStaticMarkup(<ActivitySection feed={{ jobs: [], extractions: [] }} />);
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(
<ActivityDock
<ActivitySection
feed={{
jobs: [job({ id: "j1", kind: "demo", activity: "building sandbox…", title: "Chain-rule demo" })],
extractions: [{ chatId: "cx", title: "Old lesson", phase: "merging", startedAt: NOW - 60_000 }],
}}
/>,
);
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[^>]*>—</);
assert.match(html, /data-testid="activity-abort"/, "END on the blob face, popover-independent");
assert.match(html, /\bEnd\b/, "the explicit END button");
assert.match(html, /Background activity/);
assert.match(html, /Background agents/);
// Total cost: nothing priced → "—" in the header.
assert.match(html, /activity-total-cost[^>]*>—</);
});
@@ -158,7 +163,7 @@ describe("activity dock render", () => {
steps: 1,
};
const html = renderToStaticMarkup(
<ActivityDock
<ActivitySection
feed={{
jobs: [
job({ id: "a", usage }),