The activity dock: every background process, every view, bottom-right
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.
This commit is contained in:
+7
-1
@@ -3,6 +3,7 @@ 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 { TreePanelModeProvider } from "@/components/tree-panel";
|
import { TreePanelModeProvider } from "@/components/tree-panel";
|
||||||
|
import { ActivityDock, ActivityProvider } from "@/components/activity-dock";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Learning Harness",
|
title: "Learning Harness",
|
||||||
@@ -15,7 +16,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<body className="min-h-dvh antialiased">
|
<body className="min-h-dvh antialiased">
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<TreePanelModeProvider>
|
<TreePanelModeProvider>
|
||||||
<AppShell>{children}</AppShell>
|
<ActivityProvider>
|
||||||
|
<AppShell>{children}</AppShell>
|
||||||
|
{/* The reserved bottom-right corner of every view — renders
|
||||||
|
nothing while no background process runs. */}
|
||||||
|
<ActivityDock />
|
||||||
|
</ActivityProvider>
|
||||||
</TreePanelModeProvider>
|
</TreePanelModeProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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<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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<JobKind, typeof SearchIcon> = {
|
||||||
|
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<string | null>(null); // detail popover
|
||||||
|
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) 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 (
|
||||||
|
<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 (
|
||||||
|
<div
|
||||||
|
data-testid="activity-dock"
|
||||||
|
className={cn(
|
||||||
|
"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",
|
||||||
|
narrow && "max-h-[60dvh] overflow-y-auto",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 px-1 pb-1 text-[11px] 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>
|
||||||
|
{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>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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. Until the abort backend lands, a
|
||||||
|
click fails gracefully: the button locks, the popover says why. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="activity-abort"
|
||||||
|
onClick={onAbort}
|
||||||
|
disabled={abortState !== undefined}
|
||||||
|
title={abortState === "failed" ? "Abort backend not available yet" : "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="absolute bottom-full right-0 z-40 mb-1.5 w-72 rounded-xl border bg-card p-3 text-xs shadow-xl"
|
||||||
|
>
|
||||||
|
<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 not available yet"}
|
||||||
|
{abortState === "pending" && "Stopping…"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
import "./.next/types/root-params.d.ts";
|
import "./.next/dev/types/root-params.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -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]> = {}): 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(<ActivityDock feed={{ jobs: [], extractions: [] }} />), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders one blob per running process with cost, elapsed, and the abort affordance", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<ActivityDock
|
||||||
|
feed={{
|
||||||
|
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 }],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<ActivityDock
|
||||||
|
feed={{
|
||||||
|
jobs: [
|
||||||
|
job({ id: "a", usage }),
|
||||||
|
job({ id: "b", usage, createdAt: new Date(NOW - 20_000).toISOString() }),
|
||||||
|
],
|
||||||
|
extractions: [],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// two identical priced jobs → 2 × $0.002 = $0.0040 total
|
||||||
|
assert.match(html, /activity-total-cost[^>]*>\$0\.0040</);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user