diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx index 7f89678..dc8dd56 100644 --- a/components/activity-dock.tsx +++ b/components/activity-dock.tsx @@ -54,7 +54,10 @@ export interface FeedJob extends JobRecord { } 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; @@ -145,7 +148,8 @@ export function useActivity(): { feed: ActivityFeed; refresh: () => void } { /** 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). */ + /** job.id, or the extraction job's row id (source id, or "src#i" for + * chunk jobs — unique per row, per-chunk jobs owner 2026-09-28). */ id: string; process: "job" | "extraction"; /** Sub-agent kind for jobs (extraction blobs have none). */ @@ -219,7 +223,7 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] { }); for (const run of feed.extractions) { blobs.push({ - id: run.chatId, + id: run.id, process: "extraction", phase: run.phase, title: run.title, @@ -292,7 +296,10 @@ export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) { headers: { "content-type": "application/json" }, body: JSON.stringify({ kind: blob.process === "job" ? "job" : "extraction", - id: blob.id, + // Extractions abort by SOURCE id (chatId) — chunk rows + // share one source and die together (the route's id + // charset rejects the "#" of a "src#i" job key anyway). + id: blob.process === "job" ? blob.id : blob.chatId, }), }); if (!res.ok) { diff --git a/src/server/observability.ts b/src/server/observability.ts index 8ef2cac..4644a2f 100644 --- a/src/server/observability.ts +++ b/src/server/observability.ts @@ -40,9 +40,15 @@ export interface ActivityJob extends JobRecord { contextLimit: number | null; } -/** One extraction-pipeline run (title joined), oldest first. */ +/** One extraction-pipeline JOB (title joined), oldest first. Per-chunk + * jobs (owner, 2026-09-28): a multi-chunk source owns SEVERAL rows — id + * is the unique job key (source id, or "src#i"), chatId stays the SOURCE + * id (abort + chat links are source-scoped), and the title carries the + * "· i/N" part suffix. */ export interface ActivityExtraction { + id: string; chatId: string; + part?: { i: number; n: number }; title: string; phase: ExtractionPhase; startedAt: number; @@ -75,8 +81,15 @@ export function activityFeed(): { jobs: ActivityJob[]; extractions: ActivityExtr } } const extractions: ActivityExtraction[] = extractionStatuses().map((run) => ({ + id: run.part ? `${run.chatId}#${run.part.i}` : run.chatId, chatId: run.chatId, - title: chatStore.getChat(run.chatId)?.title ?? run.chatId, + part: run.part, + // The run's own label never reaches this feed (unlike the graph + // status route) — the chat store's title, or the bare id for notes + // sources, plus the chunk suffix when the source fanned out. + title: + (run.label ?? chatStore.getChat(run.chatId)?.title ?? run.chatId) + + (run.part ? ` · ${run.part.i}/${run.part.n}` : ""), phase: run.phase, startedAt: run.startedAt, })); diff --git a/tests/render-activity-dock.test.tsx b/tests/render-activity-dock.test.tsx index c805ded..c745817 100644 --- a/tests/render-activity-dock.test.tsx +++ b/tests/render-activity-dock.test.tsx @@ -1,7 +1,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { ActivitySection, formatElapsed, toBlobs, type ActivityFeed } from "../components/activity-dock.tsx"; +import { ActivitySection, formatElapsed, toBlobs, type ActivityFeed, type FeedExtraction } from "../components/activity-dock.tsx"; import { formatTokens, formatUsd } from "../components/usage-format.ts"; // The section's pure layer: folds, formatting, and blob order — then the SSR @@ -35,7 +35,7 @@ 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 }], + extractions: [{ id: "cx", chatId: "cx", title: "Old lesson", phase: "auditing", startedAt: NOW - 100_000 }], }; const blobs = toBlobs(feed); assert.equal(blobs.length, 2); @@ -46,6 +46,17 @@ describe("activity dock pure layer", () => { assert.equal(blobs[1]!.id, "new"); }); + it("per-chunk jobs (owner, 2026-09-28): one source, N rows — unique ids (React keys), one chatId, part-suffixed titles", () => { + const rows = [ + { id: "src_a#1", chatId: "src_a", part: { i: 1, n: 2 }, title: "notes.md · 1/2", phase: "extracting", startedAt: NOW - 1000 }, + { id: "src_a#2", chatId: "src_a", part: { i: 2, n: 2 }, title: "notes.md · 2/2", phase: "queued", startedAt: NOW - 500 }, + ] as FeedExtraction[]; + const blobs = toBlobs({ jobs: [], extractions: rows }); + assert.equal(blobs.length, 2, "one blob per chunk job"); + assert.deepEqual(blobs.map((b) => b.id), ["src_a#1", "src_a#2"], "unique row ids — no duplicate React keys"); + assert.ok(blobs.every((b) => b.chatId === "src_a"), "abort/link identity stays source-scoped"); + }); + it("folds cost with jobCost; absent usage is null, never zero", () => { const priced = job({ usage: { @@ -149,7 +160,7 @@ describe("activity dock pure layer", () => { it("extraction blobs carry no money/context (—, the doctrine)", () => { const blob = toBlobs({ jobs: [], - extractions: [{ chatId: "cx", title: "T", phase: "extracting", startedAt: NOW }], + extractions: [{ id: "cx", chatId: "cx", title: "T", phase: "extracting", startedAt: NOW }], })[0]!; assert.equal(blob.cost, null); assert.equal(blob.context, null); @@ -187,7 +198,7 @@ describe("activity section render", () => { , );