Files
learnharness/components/activity-dock.tsx
T
amosblomqvist 88eb4b2ec0 practice restructure — per-problem jobs + ONE shared background-LLM pool (owner ruling 2026-09-29)
Rulings: each problem generation is its own visible dock job (the
extraction per-chunk pattern), and practice shares extraction's
semaphore — one process-wide 3-permit pool for ALL background LLM
jobs; total background LLM concurrency bounded at 3.

A. SHARED POOL: new src/agent/subagents/bg-llm-semaphore.ts —
   globalThis __lhLlmSlots, PERMIT_COUNT 3 (the ruling), the
   fresh-take/handed-off-permit deadlock discipline preserved for
   BOTH callers. extraction.ts's withExtractSlot is now a thin
   wrapper (job-row signal → withBgLlmSlot); __lhExtractSlots and
   __lhPracticeSlots both die. The partitioner now acquires a permit
   too (it's a background LLM job — it didn't before).

B. REGISTRY ROWS: the aggregate setId:generation / setId:grading rows
   are replaced by setId:partition (phase partitioning, label
   'Partitioning — title'), setId:gen:problemId (queued→generating,
   'Problem i/N — title'), setId:grade:problemId (queued→grading,
   'Grading i/N — title'). Each row carries its OWN abort controller
   and its OWN usage (one PracticeStepLog per row — the aggregate
   partitioner/generator/grader folding dies). Row key = feed id =
   abort key (the dock dup-key law).

C. ABORT SEMANTICS: abortPracticeRow(key) routes by shape — gen row:
   THAT problem only (error 'Aborted', siblings continue, set settles
   ready with one errored problem); partition row: the whole run;
   grade row: that grade drops (answer kept, re-submittable).
   abortPractice(setId) composes from row aborts + the synchronous
   persisted marks (a set-abort's verdict stands before the settles
   re-read it — the settle step keeps an existing error status).
   /api/activity/abort kind practice now takes the ROW key (charset
   admits ':').

D. FEED + DOCK: ActivityPractice/FeedPractice carry per-row identity
   (id = row key, problemIndex/problemTotal); dock blobs render per
   row, labels carry the i/N; the aggregate row is gone.

E. Boot sweep unchanged at set grain; the live-row check is now a
   key-prefix scan (any live row of the set protects it). The
   updatedAt heartbeat covers the new row scheme as-is.

Tests (20 in the pipeline suite): per-problem row shape/uniqueness/
i-N labels (partition row frozen behind a gated partitioner; per-row
usage ISOLATION pin — b's log never shows on c's row); abort-one-gen
(sibling completes, set ready with one errored problem); partition-
row abort; queued grade-row abort (dead-ticket path: answer kept,
re-submittable, sibling lands); shared-pool identity + contention
(practice queues behind extraction-style holders); registry/dock
feed mapping. Test isolation hardened: unique per-store set ids,
afterEach registry/lock/pool reset (a timed-out test can no longer
poison the rest).

architecture.md: jobs-pattern paragraph rewritten (per-problem rows,
shared pool, abort routing).

1149/1149, tsc 0, lint 0, build 0.
2026-09-29 20:42:33 +03:00

520 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIcon, BotIcon, FileTextIcon, GraduationCapIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
import { isModelsRefusal, isProviderKeyRefusal, jobCost } from "@/src/core";
import { formatTokens, formatUsd } from "@/components/usage-format";
/**
* 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 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.
* 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. 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) ────────────
/** JobUsage extended by the pending lastStep contract (the context line). */
interface FeedUsage extends NonNullable<JobRecord["usage"]> {
lastStep?: CallUsage;
}
export interface FeedJob extends JobRecord {
title: string;
usage?: FeedUsage;
/** Server-joined live catalog limit (owner amendment 2026-09-23) —
* null when the model is missing from pi's catalog (no context line). */
contextLimit: number | null;
}
export interface FeedExtraction {
/** Unique row id (source id, or "src#i" for chunk jobs). */
id: string;
chatId: string;
part?: { i: number; n: number };
title: string;
phase: string;
startedAt: number;
}
/** A live practice row — per-problem (owner ruling, 2026-09-29),
* matching the extraction per-chunk pattern. id is the ROW KEY
* (setId:partition | setId:gen:pid | setId:grade:pid) — the abort
* seam is row-keyed; the label carries the i/N. */
export interface FeedPractice {
id: string;
kind: "generation" | "grading";
title: string;
phase: string;
startedAt: number;
problemIndex?: number;
problemTotal?: number;
}
export interface ActivityFeed {
jobs: FeedJob[];
extractions: FeedExtraction[];
practice?: FeedPractice[];
}
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).
*
* ACCURACY (2026-09-19): polling alone can only be as fresh as its interval,
* so the start sites that the client can know about nudge refresh()
* directly (chat.tsx at turn start/end and dispatch; the extract/import
* buttons), and THIS provider adds the last client-known event: visibility —
* a tab becoming visible refetches at once, so hidden-tab staleness (×4)
* ends the moment the learner looks. The poll stays the safety net for
* everything with no client-visible cause (other tabs, wake turns,
* recovery).
*/
export function ActivityProvider({ children }: { children: React.ReactNode }) {
const [feed, setFeed] = useState<ActivityFeed>(EMPTY_FEED);
const [nonce, setNonce] = useState(0);
const refresh = useCallback(() => setNonce((n) => n + 1), []);
const hasRunning = feed.jobs.length > 0 || feed.extractions.length > 0 || (feed.practice?.length ?? 0) > 0;
useEffect(() => {
let alive = true;
let timer: ReturnType<typeof setTimeout> | undefined;
const poll = async () => {
try {
const res = await fetch("/api/activity");
if (res.ok) {
const data = (await res.json()) as Partial<ActivityFeed>;
if (alive)
setFeed({ jobs: data.jobs ?? [], extractions: data.extractions ?? [], practice: data.practice ?? [] });
}
} 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]);
// The visibility nudge: returning to the tab refetches immediately (and
// the re-run effect below resets the poll timer — no double polling).
useEffect(() => {
const onVisibility = () => {
if (document.visibilityState === "visible") setNonce((n) => n + 1);
};
document.addEventListener("visibilitychange", onVisibility);
return () => document.removeEventListener("visibilitychange", onVisibility);
}, []);
return <ActivityContext.Provider value={{ feed, refresh }}>{children}</ActivityContext.Provider>;
}
/** The activity feed seam for the rest of the shell: the dock's data +
* its refresh nudge. The chat page nudges on turn start (owner ground
* rules, 2026-09-15): background processes that start at send —
* extraction and sub-agent jobs — must be visible in the dock the
* moment they begin, without the dock polling faster while idle. */
export function useActivity(): { feed: ActivityFeed; refresh: () => void } {
return useContext(ActivityContext);
}
// ─── Pure presentation helpers (exported for tests) ──────────────────────────
/** One dock blob: the unified view of a job, an extraction run, or a
* practice run. */
export interface ActivityBlob {
/** job.id, the extraction job's row id (source id, or "src#i" for
* chunk jobs — unique per row, per-chunk jobs owner 2026-09-28), or
* the practice SET id (the abort seam is set-keyed). */
id: string;
process: "job" | "extraction" | "practice";
/** Sub-agent kind for jobs (extraction/practice blobs have none). */
jobKind?: JobKind;
/** Practice run kind (generation | grading) — practice blobs only. */
practiceKind?: "generation" | "grading";
/** Extraction phase (extracting/auditing/merging/embedding); 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;
// Context-meter amendment (owner 2026-09-23): the limit comes off
// the FEED (server-joined from pi's live catalog) — no client-side
// table. Null limit = no context line (usage-only render is the
// blob shape's call — here the line simply hides).
const limit = job.contextLimit;
if (usage && lastStep && limit !== null && lastStep.inputTokens !== undefined) {
// Same metric as the session meter (prices.ts stepFootprint — the
// context-meter audit, owner 2026-09-23): all four directions.
// Provider "input" EXCLUDES cacheRead/cacheWrite, so input+output
// alone reads wildly low on cache-heavy jobs.
context = {
used:
lastStep.inputTokens +
(lastStep.outputTokens ?? 0) +
(lastStep.cacheReadTokens ?? 0) +
(lastStep.cacheWriteTokens ?? 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,
// BYOK (owner, 2026-09-22): a refusal is info, not failure —
// the honest line the runner left ("failed") reads as setup
// needed, matching the dispatch card's quiet tone. The
// provider-key refusal twin (zero-keys wave, Platform's
// 66dbea9): a pinned model whose key is gone reads as the key
// being needed, same quiet tone.
activity: isModelsRefusal(job.error)
? "needs model setup"
: isProviderKeyRefusal(job.error)
? "needs a provider key"
: (job.activity ?? ""),
model: usage?.model,
steps: usage?.steps,
};
});
for (const run of feed.extractions) {
blobs.push({
id: run.id,
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: "",
});
}
for (const run of feed.practice ?? []) {
blobs.push({
id: run.id, // the ROW key — practice abort is row-keyed
process: "practice",
practiceKind: run.kind,
phase: run.phase,
title: run.title,
chatId: "", // practice is not chat-scoped
startedAt: run.startedAt,
cost: null, // practice runs ride no live cost 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`;
}
// ─── The section (the sidebar's bottom strip) ─────────────────────────────────
/** A 1s heartbeat while the section is populated — 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<JobKind, typeof SearchIcon> = {
research: SearchIcon,
demo: PlayIcon,
visual: PenToolIcon,
pdf: FileTextIcon, // sources v0 — type-forced entry (JobKind grew "pdf")
};
/** Abort states per blob id: pending (fired) / failed (backend absent). */
type AbortState = "pending" | "failed";
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 [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).
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,
// Extractions abort by SOURCE id (chatId) — chunk rows share one
// source and die together; practice aborts by SET id (blob.id).
id: blob.process === "extraction" ? blob.chatId : blob.id,
}),
});
if (!res.ok) {
// 404 not_found / 409 not_running: the process is already gone —
// the desired end state. Refresh and clear; nothing to report.
if (res.status === 404 || res.status === 409) {
abortRef.current.refresh();
set((s) => {
const { [blob.id]: _drop, ...rest } = s;
return rest;
});
return;
}
throw new Error(String(res.status));
}
abortRef.current.refresh();
} catch {
set((s) => ({ ...s, [blob.id]: "failed" }));
}
}, []);
// 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 (
<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-xs 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>
)}
</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>
);
}
/** 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.process === "practice" ? GraduationCapIcon : (blob.jobKind && KIND_ICON[blob.jobKind]) || BotIcon;
const label =
blob.process === "extraction"
? `Extraction — ${blob.phase ?? "running"}`
: blob.process === "practice"
? `Practice — ${blob.phase ?? "running"}`
: blob.jobKind === "research"
? "Researcher"
: blob.jobKind === "demo"
? "Demo builder"
: blob.jobKind === "visual"
? "Figure maker"
: blob.jobKind === "pdf"
? "PDF reader"
: "Sub-agent";
return (
<div
data-testid={`activity-blob-${blob.process}`}
className="relative rounded-lg px-2 py-1.5 text-xs transition-colors hover:bg-accent"
>
<button
type="button"
onClick={onToggleDetail}
aria-expanded={detailOpen}
className="flex w-full items-center gap-2 pr-7 text-left"
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-card-foreground">{blob.title}</span>
{blob.activity ? (
<span className="block truncate text-muted-foreground" title={blob.activity}>
{blob.activity}
</span>
) : (
<span className="block truncate text-muted-foreground/80">{label}</span>
)}
</span>
<span className="flex shrink-0 items-center gap-2 tabular-nums text-muted-foreground">
<span data-testid="activity-blob-cost">{blob.cost === null ? "—" : formatUsd(blob.cost)}</span>
<span data-testid="activity-blob-elapsed">{formatElapsed(now - blob.startedAt)}</span>
</span>
</button>
{/* The explicit END always on the blob's face (SCOPE) — the popover
is for DETAIL, not the control. A failed abort locks the button and
says why; a succeeded one just makes the blob vanish on refresh. */}
<button
type="button"
data-testid="activity-abort"
onClick={onAbort}
disabled={abortState !== undefined}
title={abortState === "failed" ? "Abort failed — try again" : "End this background process"}
aria-label={`End ${label} in ${blob.title}`}
className="absolute right-1 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
>
<SquareIcon className="size-3" />
</button>
{detailOpen && (
<div
data-testid="activity-blob-detail"
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" />
<span className="font-medium">{label}</span>
<span className="ml-auto tabular-nums text-muted-foreground">
{formatElapsed(now - blob.startedAt)}
</span>
</div>
<div className="flex flex-col gap-1 text-muted-foreground">
<div className="truncate text-card-foreground" title={blob.title}>
{blob.title}
</div>
{blob.activity && <div className="break-words">{blob.activity}</div>}
<div className="flex justify-between gap-2">
<span>Cost</span>
<span className="tabular-nums text-card-foreground">
{blob.cost === null ? "—" : formatUsd(blob.cost)}
</span>
</div>
<div className="flex justify-between gap-2">
<span>Context</span>
<span className="tabular-nums text-card-foreground" data-testid="activity-blob-context">
{blob.context === null
? "—"
: `${formatTokens(blob.context.used)} / ${formatTokens(blob.context.limit)}`}
</span>
</div>
{blob.model && (
<div className="flex justify-between gap-2">
<span>Model</span>
<span className="truncate text-card-foreground">{blob.model}</span>
</div>
)}
{blob.steps !== undefined && (
<div className="flex justify-between gap-2">
<span>Steps</span>
<span className="tabular-nums text-card-foreground">{blob.steps}</span>
</div>
)}
<a href={`/chat/${blob.chatId}`} className="text-primary hover:underline">
Open session →
</a>
</div>
<div className="mt-2 text-muted-foreground/80">
{abortState === "failed" && "Abort failed — try again"}
{abortState === "pending" && "Stopping…"}
</div>
</div>
)}
</div>
);
}