From f72a3f3adaef15811de41e0f0ab0308bfbf7aa18 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 29 Sep 2026 01:04:40 +0300 Subject: [PATCH] Activity dock: unique row ids for per-chunk extraction jobs (fix duplicate React keys) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live import surfaced it: a multi-chunk notes source renders N feed rows, and the dock keyed every one by run.chatId (the SOURCE id) — React's duplicate-key error. The activity feed also dropped run.part entirely, so all chunk rows showed identical titles. - ActivityExtraction/FeedExtraction gain a unique row id (source id, or 'src#i' for chunk jobs) and part; the feed title carries the '· i/N' suffix like the graph status route - toBlobs keys extraction blobs by the row id; chatId stays the SOURCE id (identity for links) - abort sends the SOURCE id for extractions — aborting any chunk row kills the source's whole fan-out (the new source-wide abort), and the abort route's id charset rejects '#' anyway - new pin: one source, two chunk rows → two blobs, distinct ids, same chatId; dock fixtures updated --- components/activity-dock.tsx | 13 ++++++++++--- src/server/observability.ts | 17 +++++++++++++++-- tests/render-activity-dock.test.tsx | 19 +++++++++++++++---- 3 files changed, 40 insertions(+), 9 deletions(-) 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", () => { , );