Background agents → sidebar section (owner): the retired floating dock's successor

The old activity dock (fixed bottom-right, every view) was a cross-session
widget parked inside the session view — the same boundary confusion that
got the plan window moved. The sidebar is the app's cross-session surface,
so the agents section lives at its bottom strip now (user-locked design):

- ALWAYS present: empty means empty — the header ('Background agents',
  matching the Sessions label language) sits alone; nothing hides and
  nothing decorates (the total-cost span only renders when populated).
- Populates and grows when processes run: same blobs (sub-agents +
  extraction runs, one poll, inferred cost, elapsed heartbeat), capped by
  max-h-[40dvh] + internal scroll so a pile never crowds the session list.
- Per-blob END button (unchanged abort contract); the detail popover
  becomes an INLINE expansion — the section scrolls, a floating popover
  would clip at its bounds.
- Sidebar-native skin: border-t separator matching the sidebar's edge,
  session-row hover language, no floating-card chrome.
- Sidebar closed = the learner opted out; no ambient signal for now
  (user decision — invisibility until a clearer observability story).
- Narrow-mode count badge/drawer machinery retired with the dock.

app/layout.tsx: ActivityDock mount removed, ActivityProvider stays (the
sidebar consumes the context). Live-verified: populated rows with inline
detail + session link, abort makes the row vanish, idle renders the
header alone, closed sidebar hides the section with nothing floating.
Gates: typecheck, lint, 423/423 tests.
This commit is contained in:
amosblomqvist
2026-09-14 11:56:22 +03:00
parent 38d77204ed
commit bbee72f354
4 changed files with 83 additions and 107 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 "katex/dist/katex.min.css";
import "./globals.css"; import "./globals.css";
import { AppShell, SidebarProvider } from "@/components/sidebar"; 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 // Chrome = JetBrains Mono, teaching prose = Source Sans 3, titles = CMU
// Serif (the bundled lh-cmu @font-face in globals.css — no CDN). // 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}`}> <html lang="en" className={`dark ${ui.variable} ${prose.variable}`}>
<body className="min-h-dvh antialiased"> <body className="min-h-dvh antialiased">
<SidebarProvider> <SidebarProvider>
<ActivityProvider> <ActivityProvider>
<AppShell>{children}</AppShell> <AppShell>{children}</AppShell>
{/* The reserved bottom-right corner of every view — renders </ActivityProvider>
nothing while no background process runs. */} </SidebarProvider>
<ActivityDock />
</ActivityProvider>
</SidebarProvider>
</body> </body>
</html> </html>
); );
+53 -87
View File
@@ -1,19 +1,20 @@
"use client"; "use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; 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 { cn } from "@/lib/utils";
import type { CallUsage, JobKind, JobRecord } from "@/src/core"; import type { CallUsage, JobKind, JobRecord } from "@/src/core";
import { CONTEXT_LIMITS, jobCost } from "@/src/core"; import { CONTEXT_LIMITS, jobCost } from "@/src/core";
import { formatTokens, formatUsd } from "@/components/usage-format"; import { formatTokens, formatUsd } from "@/components/usage-format";
/** /**
* THE ACTIVITY DOCK (wave 4, vision observability): the bottom-right corner * THE BACKGROUND-AGENTS SECTION (owner, sidebar round — the successor of
* of EVERY view, one blob per running BACKGROUND process — session sub-agents * 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 * (research/demo/visual) and global extraction runs. Teaching turns are
* deliberately NOT here: the dock exists to give background processes the * deliberately NOT here: the section exists to give background processes
* observability the learner can't otherwise see; an in-flight teaching turn * the observability the learner can't otherwise see; an in-flight teaching
* is fully visible where it happens (user decision, 2026-09). * turn is fully visible where it happens (user decision, 2026-09).
* *
* Data: ONE poll of GET /api/activity (jobs across ALL chats, title-joined, * Data: ONE poll of GET /api/activity (jobs across ALL chats, title-joined,
* oldest-first + extraction runs), folded client-side with core/prices.ts. * 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). * Absent data renders "—", never zero (the doctrine).
* *
* Quiet/ambient by design: no entry animations, stable keys, updates land * Quiet/ambient by design: no entry animations, stable keys, updates land
* in place — polling never flickers. Idle (no jobs, no extractions) renders * in place — polling never flickers. ALWAYS present (owner, sidebar round):
* NOTHING. Narrow viewports collapse to a count badge → click opens the * empty means EMPTY — the header sits alone, nothing hides; it populates and
* drawer. Per blob: kind icon, elapsed, cost, context, activity line, and an * GROWS when processes run (max-h + internal scroll so a pile never crowds
* explicit END button (POST /api/activity/abort — the wave-4 backend is * the session list). The sidebar's closed state means the learner opted
* live): 404/409 mean the process is already gone (refresh, no error); * out — no ambient signal while closed (user decision: invisibility until
* real failures degrade to an inline "Abort failed", never a * a clearer observability story wants building). Per blob: kind icon,
* dead control). * 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) ──────────── // ─── 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`; 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. */ /** A 1s heartbeat while the section is populated — elapsed lines tick. */
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. */
function useNow(active: boolean): number { function useNow(active: boolean): number {
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
useEffect(() => { useEffect(() => {
@@ -216,14 +209,12 @@ const KIND_ICON: Record<JobKind, typeof SearchIcon> = {
/** Abort states per blob id: pending (fired) / failed (backend absent). */ /** Abort states per blob id: pending (fired) / failed (backend absent). */
type AbortState = "pending" | "failed"; type AbortState = "pending" | "failed";
export function ActivityDock({ feed: feedProp }: { feed?: ActivityFeed }) { export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
const ctx = useContext(ActivityContext); const ctx = useContext(ActivityContext);
const feed = feedProp ?? ctx.feed; const feed = feedProp ?? ctx.feed;
const refresh = ctx.refresh; const refresh = ctx.refresh;
const blobs = useMemo(() => toBlobs(feed), [feed]); const blobs = useMemo(() => toBlobs(feed), [feed]);
const narrow = useNarrow(); const [openBlob, setOpenBlob] = useState<string | null>(null); // detail, inline-expanded
const [drawerOpen, setDrawerOpen] = useState(false);
const [openBlob, setOpenBlob] = useState<string | null>(null); // detail popover
const [abortStates, setAbortStates] = useState<Record<string, AbortState>>({}); const [abortStates, setAbortStates] = useState<Record<string, AbortState>>({});
const now = useNow(blobs.length > 0); const now = useNow(blobs.length > 0);
// Refs for stable abort handlers (no re-created closures per tick). // 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 // Sidebar-native skin (owner, sidebar round): part of the sidebar's
// surface, not a floating card — the border-t separator matches the
const count = blobs.length; // sidebar's own edge line, the header matches the "Sessions" label, the
// Narrow: a quiet count badge; the drawer opens on click. // rows match the session rows' hover. Empty means empty: the header sits
if (narrow && !drawerOpen) { // alone (the cost span is population, not decoration).
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>
);
}
return ( return (
<div <section data-testid="activity-section" className="border-t border-border px-3 pb-3 pt-2">
data-testid="activity-dock" <div className="mb-1 flex items-center gap-1.5 text-xxs uppercase tracking-widest text-muted-foreground/70">
className={cn( <ActivityIcon className="size-3" />
"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", Background agents
narrow && "max-h-[60dvh] overflow-y-auto", {blobs.length > 0 && (
)} <span className="ml-auto tabular-nums" data-testid="activity-total-cost" title="Total running cost (inferred)">
> {totalCost === null ? "—" : formatUsd(totalCost)}
<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)}
</span> </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> </div>
{blobs.map((blob) => ( {blobs.length > 0 && (
<ActivityBlobRow <div className="flex max-h-[40dvh] flex-col gap-1 overflow-y-auto">
key={blob.id} {blobs.map((blob) => (
blob={blob} <ActivityBlobRow
now={now} key={blob.id}
detailOpen={openBlob === blob.id} blob={blob}
onToggleDetail={() => setOpenBlob((id) => (id === blob.id ? null : blob.id))} now={now}
abortState={abortStates[blob.id]} detailOpen={openBlob === blob.id}
onAbort={() => void abort(blob)} onToggleDetail={() => setOpenBlob((id) => (id === blob.id ? null : blob.id))}
/> abortState={abortStates[blob.id]}
))} onAbort={() => void abort(blob)}
</div> />
))}
</div>
)}
</section>
); );
} }
@@ -356,7 +322,7 @@ function ActivityBlobRow({
return ( return (
<div <div
data-testid={`activity-blob-${blob.process}`} 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 <button
type="button" type="button"
@@ -398,7 +364,7 @@ function ActivityBlobRow({
{detailOpen && ( {detailOpen && (
<div <div
data-testid="activity-blob-detail" 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"> <div className="mb-1.5 flex items-center gap-2">
<Icon className="size-3.5 text-muted-foreground" /> <Icon className="size-3.5 text-muted-foreground" />
+10 -2
View File
@@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon } from "lucide-react"; import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon } from "lucide-react";
import { ActivitySection } from "@/components/activity-dock";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -285,8 +286,15 @@ function SessionsSidebar() {
)) ))
)} )}
</div> </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>
); );
} }
+15 -10
View File
@@ -1,11 +1,12 @@
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import assert from "node:assert/strict"; 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"; import { formatTokens, formatUsd } from "../components/usage-format.ts";
// The dock's pure layer: folds, formatting, and blob order — then the SSR // The section's pure layer: folds, formatting, and blob order — then the SSR
// render (blobs, "—" doctrine, the abort affordance, the narrow badge). // 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"); const NOW = Date.parse("2026-09-09T12:00:00Z");
@@ -118,21 +119,25 @@ describe("activity dock pure layer", () => {
}); });
}); });
describe("activity dock render", () => { describe("activity section render", () => {
it("renders NOTHING when idle (no jobs, no extractions)", () => { it("renders the ALWAYS-PRESENT header when idle — empty means empty, nothing hides", () => {
assert.equal(renderToStaticMarkup(<ActivityDock feed={{ jobs: [], extractions: [] }} />), ""); 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", () => { it("renders one blob per running process with cost, elapsed, and the abort affordance", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<ActivityDock <ActivitySection
feed={{ feed={{
jobs: [job({ id: "j1", kind: "demo", activity: "building sandbox…", title: "Chain-rule demo" })], 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 }], 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-job"/);
assert.match(html, /data-testid="activity-blob-extraction"/); assert.match(html, /data-testid="activity-blob-extraction"/);
assert.match(html, /Chain-rule demo/); assert.match(html, /Chain-rule demo/);
@@ -142,7 +147,7 @@ describe("activity dock render", () => {
assert.match(html, /activity-blob-cost[^>]*>—</); assert.match(html, /activity-blob-cost[^>]*>—</);
assert.match(html, /data-testid="activity-abort"/, "END on the blob face, popover-independent"); 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, /\bEnd\b/, "the explicit END button");
assert.match(html, /Background activity/); assert.match(html, /Background agents/);
// Total cost: nothing priced → "—" in the header. // Total cost: nothing priced → "—" in the header.
assert.match(html, /activity-total-cost[^>]*>—</); assert.match(html, /activity-total-cost[^>]*>—</);
}); });
@@ -158,7 +163,7 @@ describe("activity dock render", () => {
steps: 1, steps: 1,
}; };
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<ActivityDock <ActivitySection
feed={{ feed={{
jobs: [ jobs: [
job({ id: "a", usage }), job({ id: "a", usage }),