From e22f0bddb4fe5c30e5418a2742594016dac0f31f Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Fri, 11 Sep 2026 03:12:32 +0300 Subject: [PATCH] The activity dock: every background process, every view, bottom-right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One reserved corner of the shell, 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 surface what the learner can't otherwise see; a running teaching turn is already fully visible where it happens (user decision). - Data: ONE poll of GET /api/activity (jobs across ALL chats, title-joined, oldest-first + extraction runs), folded client-side with core/prices.ts (jobCost). Adaptive interval — 2.5s while anything runs, 10s idle, 4× slower on hidden tabs; a failed fetch keeps the last feed (no flicker to empty on a blip). - Per blob: kind icon, elapsed (ticking), cost, activity line, and an explicit END on the blob's face. Click → detail popover (context line, model, steps, open-session link). The context line is the LAST model call's tokens vs the model's limit (JobUsage.lastStep — pending agents-stream contract; "—" until it populates), NEVER a cumulative sum. Absent data renders "—", never zero (the doctrine). - Header: total running cost (sum of priced blobs, "—" when nothing is priced). Narrow viewports collapse to a count badge → drawer. - END posts to /api/activity/abort {kind, id} — the backend is being composed by master over the streams' mechanisms; until it exists a click locks the button and the popover says "Abort not available yet" (never a dead control). - Idle renders NOTHING. Mounted once in the root layout; every view gets it for free. toBlobs / formatElapsed / formatUsd / formatTokens are pure + tested; SSR render tests pin blobs, the "—" doctrine, the face END, and the priced total. --- app/layout.tsx | 8 +- components/activity-dock.tsx | 451 ++++++++++++++++++++++++++++ next-env.d.ts | 4 +- tests/render-activity-dock.test.tsx | 172 +++++++++++ 4 files changed, 632 insertions(+), 3 deletions(-) create mode 100644 components/activity-dock.tsx create mode 100644 tests/render-activity-dock.test.tsx 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 ( + + ); + } + + return ( +
+
+ + Background activity + + {totalCost === null ? "—" : formatUsd(totalCost)} + + {narrow && ( + + )} +
+ {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 ( +
+ + {/* 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[^>]*>—]*>— { + 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