Files
learnharness/components/activity-dock.tsx
T
amosblomqvist 31978f3fc3 fix: background-delivery ground rules — the dock is the source of truth, delivered content shows the moment it persists
The owner's three-part test of the context-injection flow surfaced
timing/visibility bugs, fixed as standing ground rules for ALL background
processes (owner rules, 2026-09-15):

1. THE JOB STARTS AT SEND. triggerGraphContext moves from onFinish to
   right after the write-ahead persist in handleChatRequest: its only
   input is the first typed message (the topic), just made durable — so
   embedding + cosine scan run CONCURRENT with the prober's turn and
   the dock blob appears the moment the session starts, not after turn
   one finishes. Extraction stays at onFinish (it needs answers
   accumulated DURING the turn).

2. DELIVERED CONTENT SHOWS MID-TURN. Steers inserted server-side
   (claimed at turn start, persisted write-ahead) used to wait for the
   next turn's completion to appear — the full record sync mid-stream
   is forbidden (it breaks the SDK's in-place continuation merge), so
   adoption was deferred to idle. New append-only seam: core
   mergeAdopted() inserts the record's new message ids in canonical
   order (in-flight streaming tail kept last) and never rewrites a
   local message; any other shape bails to the full-sync-at-idle net.
   Wired at three points, no new standing polls:
   - turn-start window: three bounded record fetches (400ms/1s/2s)
     after a send catch the claimed steer while the turn streams;
     self-terminating, survives the submitted->streaming flip.
   - jobs-poll settle: mid-turn settles adopt append-only NOW, the
     owed full sync still runs at idle.
   - wake-adoption poll: each tick adopts persisted wake content, so
     steers show at the wake turn's START, not its finish.
   The dock stays honest with zero gap: jobs hold "running" while
   queued behind a pending question ("delivering context"), then flip
   done at claim/delivery — visible from start to delivery, and the
   turn-start nudge (useActivity().refresh) clears it within ~150ms.

3. THE MARKER IS CLICKABLE (observability). The knowledge-context
   SystemLine becomes GraphContextMarker: quiet line, popover with the
   delivered propositions IN DELIVERY ORDER (most similar first — the
   order the budget filled), each with its folded understanding level;
   Esc / outside pointerdown close. The part data was already the audit
   trail — now it's readable at the seam where it landed.

Tests: mergeAdopted unit pins (6), marker render pins (3) — 468/468,
tsc, eslint, build all green; live-smoked end-to-end: blob at send,
marker at t+500ms mid-turn, popover order + levels, instant dock clear.
2026-09-15 16:34:25 +03:00

431 lines
16 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, BookOpenIcon, 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 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;
}
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<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;
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 ?? [] });
}
} 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 <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): a background process that starts at send — the
* knowledge-context injection — must be visible in the dock the moment
* it begins, 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 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`;
}
// ─── 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,
"graph-context": BookOpenIcon,
};
/** 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 === "job" ? "job" : "extraction",
id: 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-xxs 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.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"
: blob.jobKind === "graph-context"
? "Knowledge context"
: "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>
);
}