diff --git a/app/layout.tsx b/app/layout.tsx
index f8c1df1..bb741c3 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -3,6 +3,7 @@ import "katex/dist/katex.min.css";
import "./globals.css";
import { AppShell, SidebarProvider } from "@/components/sidebar";
import { TreePanelModeProvider } from "@/components/tree-panel";
+import { ActivityDock, ActivityProvider } from "@/components/activity-dock";
export const metadata: Metadata = {
title: "Learning Harness",
@@ -15,7 +16,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
- {children}
+
+ {children}
+ {/* The reserved bottom-right corner of every view — renders
+ nothing while no background process runs. */}
+
+
diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx
new file mode 100644
index 0000000..4230ecb
--- /dev/null
+++ b/components/activity-dock.tsx
@@ -0,0 +1,451 @@
+"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 { cn } from "@/lib/utils";
+import type { CallUsage, JobKind, JobRecord } from "@/src/core";
+import { CONTEXT_LIMITS, jobCost } from "@/src/core";
+
+/**
+ * THE ACTIVITY DOCK (wave 4, vision observability): the bottom-right corner
+ * of EVERY view, 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).
+ *
+ * Data: ONE poll of GET /api/activity (jobs across ALL chats, title-joined,
+ * oldest-first + extraction runs), folded client-side with core/prices.ts.
+ * Cost is INFERRED (jobCost); the context line is the LAST model call's
+ * input+output against the model's limit (JobUsage.lastStep — pending
+ * contract) — never a cumulative sum, which would be the wrong number.
+ * 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 backend lands with the
+ * agents stream; failures degrade to an inline "not available yet", never a
+ * dead control).
+ */
+
+// ─── Wire shapes (client-safe mirrors of server/observability.ts) ────────────
+
+/** JobUsage extended by the pending lastStep contract (the context line). */
+interface FeedUsage extends NonNullable {
+ lastStep?: CallUsage;
+}
+
+export interface FeedJob extends JobRecord {
+ title: string;
+ usage?: FeedUsage;
+}
+
+export interface FeedExtraction {
+ chatId: string;
+ title: string;
+ phase: string;
+ startedAt: number;
+}
+
+export interface ActivityFeed {
+ jobs: FeedJob[];
+ extractions: FeedExtraction[];
+}
+
+const EMPTY_FEED: ActivityFeed = { jobs: [], extractions: [] };
+
+// ─── The one poll (composition root: the root layout) ────────────────────────
+
+const ActivityContext = createContext<{ feed: ActivityFeed; refresh: () => void }>({
+ feed: EMPTY_FEED,
+ refresh: () => {},
+});
+
+/**
+ * Polls /api/activity for the whole app shell. Adaptive interval: fast while
+ * anything runs, slow when idle, ~4× slower on hidden tabs. A failed fetch
+ * keeps the last feed (the dock never flickers to empty on a blip).
+ */
+export function ActivityProvider({ children }: { children: React.ReactNode }) {
+ const [feed, setFeed] = useState(EMPTY_FEED);
+ const [nonce, setNonce] = useState(0);
+ const refresh = useCallback(() => setNonce((n) => n + 1), []);
+ const hasRunning = feed.jobs.length > 0 || feed.extractions.length > 0;
+
+ useEffect(() => {
+ let alive = true;
+ let timer: ReturnType | undefined;
+ const poll = async () => {
+ try {
+ const res = await fetch("/api/activity");
+ if (res.ok) {
+ const data = (await res.json()) as Partial;
+ if (alive)
+ setFeed({ jobs: data.jobs ?? [], extractions: data.extractions ?? [] });
+ }
+ } catch {
+ // transport blip: keep the last feed, try again
+ }
+ if (!alive) return;
+ const base = hasRunning ? 2500 : 10000;
+ timer = setTimeout(poll, document.hidden ? base * 4 : base);
+ };
+ void poll();
+ return () => {
+ alive = false;
+ if (timer) clearTimeout(timer);
+ };
+ }, [hasRunning, nonce]);
+
+ return {children} ;
+}
+
+// ─── Pure presentation helpers (exported for tests) ──────────────────────────
+
+/** One dock blob: the unified view of a job or an extraction run. */
+export interface ActivityBlob {
+ /** job.id, or the extraction's chatId (one run per chat). */
+ id: string;
+ process: "job" | "extraction";
+ /** Sub-agent kind for jobs (extraction blobs have none). */
+ jobKind?: JobKind;
+ /** Extraction phase (extracting/auditing/merging); jobs carry none. */
+ phase?: string;
+ title: string;
+ chatId: string;
+ /** ms epoch — blobs order oldest-first and elapsed counts from it. */
+ startedAt: number;
+ /** Inferred USD cost (jobCost) — null renders "—". */
+ cost: number | null;
+ /** Last model call's footprint vs the model's limit — null renders "—". */
+ context: { used: number; limit: number } | null;
+ /** Latest human-readable activity line (""). */
+ activity: string;
+ /** Sub-agent model id, when usage is reported. */
+ model?: string;
+ /** Model calls made so far, when usage is reported. */
+ steps?: number;
+}
+
+/** Merge the feed into one oldest-first blob list (the stable blob order). */
+export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
+ const blobs: ActivityBlob[] = feed.jobs.map((job) => {
+ const usage = job.usage;
+ let context: ActivityBlob["context"] = null;
+ const lastStep = usage?.lastStep;
+ const limit = usage ? CONTEXT_LIMITS[usage.model] : undefined;
+ if (usage && lastStep && limit !== undefined && lastStep.inputTokens !== undefined) {
+ context = { used: lastStep.inputTokens + (lastStep.outputTokens ?? 0), limit };
+ }
+ return {
+ id: job.id,
+ process: "job",
+ jobKind: job.kind,
+ title: job.title,
+ chatId: job.chatId,
+ startedAt: Date.parse(job.createdAt) || 0,
+ cost: jobCost(job),
+ context,
+ activity: job.activity ?? "",
+ model: usage?.model,
+ steps: usage?.steps,
+ };
+ });
+ for (const run of feed.extractions) {
+ blobs.push({
+ id: run.chatId,
+ process: "extraction",
+ phase: run.phase,
+ title: run.title,
+ chatId: run.chatId,
+ startedAt: run.startedAt,
+ cost: null, // extraction runs don't ride the money channel (v1)
+ context: null,
+ activity: "",
+ });
+ }
+ blobs.sort((a, b) => a.startedAt - b.startedAt);
+ return blobs;
+}
+
+/** Elapsed as a compact ticking string: 45s / 3m 04s / 1h 12m. */
+export function formatElapsed(ms: number): string {
+ const s = Math.max(0, Math.floor(ms / 1000));
+ if (s < 60) return `${s}s`;
+ const m = Math.floor(s / 60);
+ if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
+ return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
+}
+
+/** USD for cent-scale money: >= $1 keeps two decimals, smaller keeps four. */
+export function formatUsd(c: number): string {
+ return c >= 1 ? `$${c.toFixed(2)}` : `$${c.toFixed(4)}`;
+}
+
+/** 38000 → "38k", 1234567 → "1.2M" — trimmed, never "38.0k". */
+export function formatTokens(n: number): string {
+ if (n >= 1e6) return `${+(n / 1e6).toFixed(1)}M`;
+ if (n >= 1e3) return `${+(n / 1e3).toFixed(1)}k`;
+ return String(n);
+}
+
+// ─── The dock ─────────────────────────────────────────────────────────────────
+
+/** 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. */
+function useNow(active: boolean): number {
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => {
+ if (!active) return;
+ const t = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(t);
+ }, [active]);
+ return now;
+}
+
+const KIND_ICON: Record = {
+ research: SearchIcon,
+ demo: PlayIcon,
+ visual: PenToolIcon,
+};
+
+/** Abort states per blob id: pending (fired) / failed (backend absent). */
+type AbortState = "pending" | "failed";
+
+export function ActivityDock({ 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 [abortStates, setAbortStates] = useState>({});
+ const now = useNow(blobs.length > 0);
+ // Refs for stable abort handlers (no re-created closures per tick).
+ const abortRef = useRef({ blobs, refresh, setAbortStates });
+ abortRef.current = { blobs, refresh, setAbortStates };
+
+ const totalCost = useMemo(() => {
+ const priced = blobs.map((b) => b.cost).filter((c): c is number => c !== null);
+ return priced.length === 0 ? null : priced.reduce((a, c) => a + c, 0);
+ }, [blobs]);
+
+ const abort = useCallback(async (blob: ActivityBlob) => {
+ const { setAbortStates: set } = abortRef.current;
+ set((s) => ({ ...s, [blob.id]: "pending" }));
+ try {
+ const res = await fetch("/api/activity/abort", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ kind: blob.process === "job" ? "job" : "extraction",
+ id: blob.id,
+ }),
+ });
+ if (!res.ok) throw new Error(String(res.status));
+ abortRef.current.refresh();
+ } catch {
+ set((s) => ({ ...s, [blob.id]: "failed" }));
+ }
+ }, []);
+
+ 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 (
+ 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"
+ >
+
+ {count}
+
+ );
+ }
+
+ return (
+
+
+
+ Background activity
+
+ {totalCost === null ? "—" : formatUsd(totalCost)}
+
+ {narrow && (
+
setDrawerOpen(false)}
+ aria-label="Hide activity dock"
+ className="text-muted-foreground transition-colors hover:text-foreground"
+ >
+
+
+ )}
+
+ {blobs.map((blob) => (
+
setOpenBlob((id) => (id === blob.id ? null : blob.id))}
+ abortState={abortStates[blob.id]}
+ onAbort={() => void abort(blob)}
+ />
+ ))}
+
+ );
+}
+
+/** One blob: icon + elapsed + cost on the face; click → detail popover. */
+function ActivityBlobRow({
+ blob,
+ now,
+ detailOpen,
+ onToggleDetail,
+ abortState,
+ onAbort,
+}: {
+ blob: ActivityBlob;
+ now: number;
+ detailOpen: boolean;
+ onToggleDetail: () => void;
+ abortState: AbortState | undefined;
+ onAbort: () => void;
+}) {
+ const Icon = blob.process === "extraction" ? WaypointsIcon : (blob.jobKind && KIND_ICON[blob.jobKind]) || BotIcon;
+ const label =
+ blob.process === "extraction"
+ ? `Extraction — ${blob.phase ?? "running"}`
+ : blob.jobKind === "research"
+ ? "Researcher"
+ : blob.jobKind === "demo"
+ ? "Demo builder"
+ : blob.jobKind === "visual"
+ ? "Figure maker"
+ : "Sub-agent";
+ return (
+
+
+
+
+ {blob.title}
+ {blob.activity ? (
+
+ {blob.activity}
+
+ ) : (
+ {label}
+ )}
+
+
+ {blob.cost === null ? "—" : formatUsd(blob.cost)}
+ {formatElapsed(now - blob.startedAt)}
+
+
+ {/* The explicit END always on the blob's face (SCOPE) — the popover
+ is for DETAIL, not the control. Until the abort backend lands, a
+ click fails gracefully: the button locks, the popover says why. */}
+
+
+
+
+ {detailOpen && (
+
+
+
+ {label}
+
+ {formatElapsed(now - blob.startedAt)}
+
+
+
+
+ {blob.title}
+
+ {blob.activity &&
{blob.activity}
}
+
+ Cost
+
+ {blob.cost === null ? "—" : formatUsd(blob.cost)}
+
+
+
+ Context
+
+ {blob.context === null
+ ? "—"
+ : `${formatTokens(blob.context.used)} / ${formatTokens(blob.context.limit)}`}
+
+
+ {blob.model && (
+
+ Model
+ {blob.model}
+
+ )}
+ {blob.steps !== undefined && (
+
+ Steps
+ {blob.steps}
+
+ )}
+
+ Open session →
+
+
+
+ {abortState === "failed" && "Abort not available yet"}
+ {abortState === "pending" && "Stopping…"}
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/next-env.d.ts b/next-env.d.ts
index ce4e94a..a419cbe 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,7 +1,7 @@
///
///
-import "./.next/types/routes.d.ts";
-import "./.next/types/root-params.d.ts";
+import "./.next/dev/types/routes.d.ts";
+import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/tests/render-activity-dock.test.tsx b/tests/render-activity-dock.test.tsx
new file mode 100644
index 0000000..749b22a
--- /dev/null
+++ b/tests/render-activity-dock.test.tsx
@@ -0,0 +1,172 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { ActivityDock, formatElapsed, formatTokens, formatUsd, toBlobs, type ActivityFeed } from "../components/activity-dock.tsx";
+
+// The dock's pure layer: folds, formatting, and blob order — then the SSR
+// render (blobs, "—" doctrine, the abort affordance, the narrow badge).
+
+const NOW = Date.parse("2026-09-09T12:00:00Z");
+
+/** A full CallUsage (its fields are undefined-valued, not optional). */
+function step(inputTokens: number, outputTokens: number) {
+ return { inputTokens, outputTokens, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined };
+}
+
+function job(over: Partial = {}): ActivityFeed["jobs"][number] {
+ return {
+ id: "job-1",
+ kind: "research",
+ chatId: "c1",
+ toolCallId: "t1",
+ status: "running",
+ activity: 'web_search: "sigma.js v3"',
+ createdAt: new Date(NOW - 45_000).toISOString(),
+ updatedAt: new Date(NOW - 1000).toISOString(),
+ title: "Limits lesson",
+ ...over,
+ };
+}
+
+describe("activity dock pure layer", () => {
+ it("merges jobs + extractions oldest-first (the stable blob order)", () => {
+ const feed: ActivityFeed = {
+ jobs: [job({ id: "new", createdAt: new Date(NOW - 10_000).toISOString() })],
+ extractions: [{ chatId: "cx", title: "Old lesson", phase: "auditing", startedAt: NOW - 100_000 }],
+ };
+ const blobs = toBlobs(feed);
+ assert.equal(blobs.length, 2);
+ const first = blobs[0]!;
+ assert.equal(first.id, "cx"); // extraction started first
+ assert.equal(first.process, "extraction");
+ assert.equal(first.phase, "auditing");
+ assert.equal(blobs[1]!.id, "new");
+ });
+
+ it("folds cost with jobCost; absent usage is null, never zero", () => {
+ const priced = job({
+ usage: {
+ model: "z-ai/glm-5.3-flash",
+ inputTokens: 10_000,
+ outputTokens: 1000,
+ cacheReadTokens: 5000,
+ cacheWriteTokens: 0,
+ reasoningTokens: 0,
+ steps: 2,
+ },
+ });
+ const blob = toBlobs({ jobs: [priced], extractions: [] })[0]!;
+ // (10000-5000)/1e6*0.15 + 5000/1e6*0.03 + 1000/1e6*0.5 = 0.0014
+ assert.ok(blob.cost !== null && blob.cost > 0.0013 && blob.cost < 0.0015);
+ const absent = toBlobs({ jobs: [job()], extractions: [] })[0]!;
+ assert.equal(absent.cost, null);
+ });
+
+ it("context line = LAST step's tokens vs the model limit, never a cumulative sum", () => {
+ const base = {
+ model: "z-ai/glm-5.3-flash",
+ inputTokens: 400_000, // running total (much bigger — must NOT feed the line)
+ outputTokens: 8_000,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ reasoningTokens: 0,
+ steps: 5,
+ };
+ const withLast = job({
+ usage: { ...base, lastStep: step(90_000, 2_000) },
+ });
+ const blob = toBlobs({ jobs: [withLast], extractions: [] })[0]!;
+ assert.equal(blob.context?.used, 92_000);
+ assert.equal(blob.context?.limit, 1310720);
+ // No lastStep yet (capture pending) → "—", even though usage exists.
+ const stripped = toBlobs({ jobs: [job({ usage: { ...base } })], extractions: [] })[0]!;
+ assert.equal(stripped.context, null);
+ // Unknown model → no limit → "—".
+ const unknownModel = toBlobs({
+ jobs: [job({ usage: { ...base, model: "nope/unknown", lastStep: step(5, 5) } })],
+ extractions: [],
+ })[0]!;
+ assert.equal(unknownModel.context, null);
+ });
+
+ it("extraction blobs carry no money/context (—, the doctrine)", () => {
+ const blob = toBlobs({
+ jobs: [],
+ extractions: [{ chatId: "cx", title: "T", phase: "extracting", startedAt: NOW }],
+ })[0]!;
+ assert.equal(blob.cost, null);
+ assert.equal(blob.context, null);
+ });
+
+ it("formatElapsed ticks compactly", () => {
+ assert.equal(formatElapsed(45_000), "45s");
+ assert.equal(formatElapsed(184_000), "3m 04s");
+ assert.equal(formatElapsed(4_320_000), "1h 12m");
+ });
+
+ it("formatUsd keeps four decimals under $1, two above", () => {
+ assert.equal(formatUsd(0.0014), "$0.0014");
+ assert.equal(formatUsd(1.5), "$1.50");
+ });
+
+ it("formatTokens trims (never 38.0k)", () => {
+ assert.equal(formatTokens(38_000), "38k");
+ assert.equal(formatTokens(1_234_567), "1.2M");
+ assert.equal(formatTokens(950), "950");
+ });
+});
+
+describe("activity dock render", () => {
+ it("renders NOTHING when idle (no jobs, no extractions)", () => {
+ assert.equal(renderToStaticMarkup( ), "");
+ });
+
+ 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-blob-job"/);
+ assert.match(html, /data-testid="activity-blob-extraction"/);
+ assert.match(html, /Chain-rule demo/);
+ assert.match(html, /building sandbox…/);
+ assert.match(html, /Extraction — merging/);
+ // Unpriced job → "—", never $0.0000 (doctrine); both blobs show it.
+ 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/);
+ // Total cost: nothing priced → "—" in the header.
+ assert.match(html, /activity-total-cost[^>]*>—);
+ });
+
+ it("shows total running cost when jobs are priced", () => {
+ const usage = {
+ model: "z-ai/glm-5.3-flash",
+ inputTokens: 10_000,
+ outputTokens: 1000,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ reasoningTokens: 0,
+ steps: 1,
+ };
+ const html = renderToStaticMarkup(
+ ,
+ );
+ // two identical priced jobs → 2 × $0.002 = $0.0040 total
+ assert.match(html, /activity-total-cost[^>]*>\$0\.0040);
+ });
+});
\ No newline at end of file