Activity dock: unique row ids for per-chunk extraction jobs (fix duplicate React keys)

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
This commit is contained in:
amosblomqvist
2026-09-29 01:04:40 +03:00
parent d047e7b2dc
commit f72a3f3ada
3 changed files with 40 additions and 9 deletions
+15 -4
View File
@@ -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", () => {
<ActivitySection
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 }],
extractions: [{ id: "cx", chatId: "cx", title: "Old lesson", phase: "merging", startedAt: NOW - 60_000 }],
}}
/>,
);