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:
@@ -54,7 +54,10 @@ export interface FeedJob extends JobRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedExtraction {
|
export interface FeedExtraction {
|
||||||
|
/** Unique row id (source id, or "src#i" for chunk jobs). */
|
||||||
|
id: string;
|
||||||
chatId: string;
|
chatId: string;
|
||||||
|
part?: { i: number; n: number };
|
||||||
title: string;
|
title: string;
|
||||||
phase: string;
|
phase: string;
|
||||||
startedAt: number;
|
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. */
|
/** One dock blob: the unified view of a job or an extraction run. */
|
||||||
export interface ActivityBlob {
|
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;
|
id: string;
|
||||||
process: "job" | "extraction";
|
process: "job" | "extraction";
|
||||||
/** Sub-agent kind for jobs (extraction blobs have none). */
|
/** 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) {
|
for (const run of feed.extractions) {
|
||||||
blobs.push({
|
blobs.push({
|
||||||
id: run.chatId,
|
id: run.id,
|
||||||
process: "extraction",
|
process: "extraction",
|
||||||
phase: run.phase,
|
phase: run.phase,
|
||||||
title: run.title,
|
title: run.title,
|
||||||
@@ -292,7 +296,10 @@ export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
|
|||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
kind: blob.process === "job" ? "job" : "extraction",
|
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) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -40,9 +40,15 @@ export interface ActivityJob extends JobRecord {
|
|||||||
contextLimit: number | null;
|
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 {
|
export interface ActivityExtraction {
|
||||||
|
id: string;
|
||||||
chatId: string;
|
chatId: string;
|
||||||
|
part?: { i: number; n: number };
|
||||||
title: string;
|
title: string;
|
||||||
phase: ExtractionPhase;
|
phase: ExtractionPhase;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
@@ -75,8 +81,15 @@ export function activityFeed(): { jobs: ActivityJob[]; extractions: ActivityExtr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const extractions: ActivityExtraction[] = extractionStatuses().map((run) => ({
|
const extractions: ActivityExtraction[] = extractionStatuses().map((run) => ({
|
||||||
|
id: run.part ? `${run.chatId}#${run.part.i}` : run.chatId,
|
||||||
chatId: 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,
|
phase: run.phase,
|
||||||
startedAt: run.startedAt,
|
startedAt: run.startedAt,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
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";
|
import { formatTokens, formatUsd } from "../components/usage-format.ts";
|
||||||
|
|
||||||
// The section's pure layer: folds, formatting, and blob order — then the SSR
|
// 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)", () => {
|
it("merges jobs + extractions oldest-first (the stable blob order)", () => {
|
||||||
const feed: ActivityFeed = {
|
const feed: ActivityFeed = {
|
||||||
jobs: [job({ id: "new", createdAt: new Date(NOW - 10_000).toISOString() })],
|
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);
|
const blobs = toBlobs(feed);
|
||||||
assert.equal(blobs.length, 2);
|
assert.equal(blobs.length, 2);
|
||||||
@@ -46,6 +46,17 @@ describe("activity dock pure layer", () => {
|
|||||||
assert.equal(blobs[1]!.id, "new");
|
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", () => {
|
it("folds cost with jobCost; absent usage is null, never zero", () => {
|
||||||
const priced = job({
|
const priced = job({
|
||||||
usage: {
|
usage: {
|
||||||
@@ -149,7 +160,7 @@ describe("activity dock pure layer", () => {
|
|||||||
it("extraction blobs carry no money/context (—, the doctrine)", () => {
|
it("extraction blobs carry no money/context (—, the doctrine)", () => {
|
||||||
const blob = toBlobs({
|
const blob = toBlobs({
|
||||||
jobs: [],
|
jobs: [],
|
||||||
extractions: [{ chatId: "cx", title: "T", phase: "extracting", startedAt: NOW }],
|
extractions: [{ id: "cx", chatId: "cx", title: "T", phase: "extracting", startedAt: NOW }],
|
||||||
})[0]!;
|
})[0]!;
|
||||||
assert.equal(blob.cost, null);
|
assert.equal(blob.cost, null);
|
||||||
assert.equal(blob.context, null);
|
assert.equal(blob.context, null);
|
||||||
@@ -187,7 +198,7 @@ describe("activity section render", () => {
|
|||||||
<ActivitySection
|
<ActivitySection
|
||||||
feed={{
|
feed={{
|
||||||
jobs: [job({ id: "j1", kind: "demo", activity: "building sandbox…", title: "Chain-rule demo" })],
|
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 }],
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user