diff --git a/app/layout.tsx b/app/layout.tsx index 2a19db7..5719f69 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,7 +3,7 @@ import { JetBrains_Mono, Source_Sans_3 } from "next/font/google"; import "katex/dist/katex.min.css"; import "./globals.css"; import { AppShell, SidebarProvider } from "@/components/sidebar"; -import { ActivityDock, ActivityProvider } from "@/components/activity-dock"; +import { ActivityProvider } from "@/components/activity-dock"; // Chrome = JetBrains Mono, teaching prose = Source Sans 3, titles = CMU // Serif (the bundled lh-cmu @font-face in globals.css — no CDN). @@ -20,13 +20,10 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - - {children} - {/* The reserved bottom-right corner of every view — renders - nothing while no background process runs. */} - - - + + {children} + + ); diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx index 3eab877..f965409 100644 --- a/components/activity-dock.tsx +++ b/components/activity-dock.tsx @@ -1,19 +1,20 @@ "use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIcon, BotIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon, XIcon } from "lucide-react"; +import { ActivityIcon, BotIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import type { CallUsage, JobKind, JobRecord } from "@/src/core"; import { CONTEXT_LIMITS, jobCost } from "@/src/core"; import { formatTokens, formatUsd } from "@/components/usage-format"; /** - * THE ACTIVITY DOCK (wave 4, vision observability): the bottom-right corner - * of EVERY view, one blob per running BACKGROUND process — session sub-agents + * THE BACKGROUND-AGENTS SECTION (owner, sidebar round — the successor of + * the retired bottom-right floating dock): the bottom strip of the LEFT + * SIDEBAR, one blob per running BACKGROUND process — session sub-agents * (research/demo/visual) and global extraction runs. Teaching turns are - * deliberately NOT here: the dock exists to give background processes the - * observability the learner can't otherwise see; an in-flight teaching turn - * is fully visible where it happens (user decision, 2026-09). + * deliberately NOT here: the section exists to give background processes + * the observability the learner can't otherwise see; an in-flight teaching + * turn is fully visible where it happens (user decision, 2026-09). * * Data: ONE poll of GET /api/activity (jobs across ALL chats, title-joined, * oldest-first + extraction runs), folded client-side with core/prices.ts. @@ -23,13 +24,18 @@ import { formatTokens, formatUsd } from "@/components/usage-format"; * Absent data renders "—", never zero (the doctrine). * * Quiet/ambient by design: no entry animations, stable keys, updates land - * in place — polling never flickers. Idle (no jobs, no extractions) renders - * NOTHING. Narrow viewports collapse to a count badge → click opens the - * drawer. Per blob: kind icon, elapsed, cost, context, activity line, and an - * explicit END button (POST /api/activity/abort — the wave-4 backend is - * live): 404/409 mean the process is already gone (refresh, no error); - * real failures degrade to an inline "Abort failed", never a - * dead control). + * in place — polling never flickers. ALWAYS present (owner, sidebar round): + * empty means EMPTY — the header sits alone, nothing hides; it populates and + * GROWS when processes run (max-h + internal scroll so a pile never crowds + * the session list). The sidebar's closed state means the learner opted + * out — no ambient signal while closed (user decision: invisibility until + * a clearer observability story wants building). Per blob: kind icon, + * elapsed, cost, activity line, an explicit END button (POST + * /api/activity/abort — the wave-4 backend is live): 404/409 mean the + * process is already gone (refresh, no error); real failures degrade to + * an inline "Abort failed", never a dead control. Detail (context, model, + * steps, session link) EXPANDS INLINE — the section scrolls, so a + * floating popover would clip at its bounds. */ // ─── Wire shapes (client-safe mirrors of server/observability.ts) ──────────── @@ -181,22 +187,9 @@ export function formatElapsed(ms: number): string { return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`; } -// ─── The dock ───────────────────────────────────────────────────────────────── +// ─── The section (the sidebar's bottom strip) ───────────────────────────────── -/** Narrow viewports (< sm) collapse the dock to a count badge → drawer. */ -function useNarrow(): boolean { - const [narrow, setNarrow] = useState(false); - useEffect(() => { - const mq = window.matchMedia("(max-width: 639px)"); - const apply = () => setNarrow(mq.matches); - apply(); - mq.addEventListener("change", apply); - return () => mq.removeEventListener("change", apply); - }, []); - return narrow; -} - -/** A 1s heartbeat while the dock is on screen — elapsed lines tick. */ +/** A 1s heartbeat while the section is populated — elapsed lines tick. */ function useNow(active: boolean): number { const [now, setNow] = useState(() => Date.now()); useEffect(() => { @@ -216,14 +209,12 @@ const KIND_ICON: Record = { /** Abort states per blob id: pending (fired) / failed (backend absent). */ type AbortState = "pending" | "failed"; -export function ActivityDock({ feed: feedProp }: { feed?: ActivityFeed }) { +export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) { const ctx = useContext(ActivityContext); const feed = feedProp ?? ctx.feed; const refresh = ctx.refresh; const blobs = useMemo(() => toBlobs(feed), [feed]); - const narrow = useNarrow(); - const [drawerOpen, setDrawerOpen] = useState(false); - const [openBlob, setOpenBlob] = useState(null); // detail popover + const [openBlob, setOpenBlob] = useState(null); // detail, inline-expanded const [abortStates, setAbortStates] = useState>({}); const now = useNow(blobs.length > 0); // Refs for stable abort handlers (no re-created closures per tick). @@ -266,63 +257,38 @@ export function ActivityDock({ feed: feedProp }: { feed?: ActivityFeed }) { } }, []); - if (blobs.length === 0) return null; // idle: the dock is invisible - - const count = blobs.length; - // Narrow: a quiet count badge; the drawer opens on click. - if (narrow && !drawerOpen) { - return ( - - ); - } - + // Sidebar-native skin (owner, sidebar round): part of the sidebar's + // surface, not a floating card — the border-t separator matches the + // sidebar's own edge line, the header matches the "Sessions" label, the + // rows match the session rows' hover. Empty means empty: the header sits + // alone (the cost span is population, not decoration). return ( -
-
- - Background activity - - {totalCost === null ? "—" : formatUsd(totalCost)} +
+
+ + Background agents + {blobs.length > 0 && ( + + {totalCost === null ? "—" : formatUsd(totalCost)} - {narrow && ( - )}
- {blobs.map((blob) => ( - setOpenBlob((id) => (id === blob.id ? null : blob.id))} - abortState={abortStates[blob.id]} - onAbort={() => void abort(blob)} - /> - ))} -
+ {blobs.length > 0 && ( +
+ {blobs.map((blob) => ( + setOpenBlob((id) => (id === blob.id ? null : blob.id))} + abortState={abortStates[blob.id]} + onAbort={() => void abort(blob)} + /> + ))} +
+ )} + ); } @@ -356,7 +322,7 @@ function ActivityBlobRow({ return (
- + + {/* 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( -