From 4001c3465bcbc43204fb3d124baf8ca3fe232597 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 29 Sep 2026 01:26:23 +0300 Subject: [PATCH] Graph view: unique pill keys for chunk jobs (second duplicate-key consumer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The graph page's pipeline pills keyed rows by run.chatId — the source id, shared by every chunk job of a fan-out (the dock got the same fix in 8f66f47; this consumer was missed). /api/graph/status rows now carry an id (the registry job key: source id, or 'src#i'), and the pills key by it. chatId stays the SOURCE id — the reveal refetch and per-run cost refresh track chatId SETS, which dedupe naturally and fire when the source's LAST row leaves the feed. --- app/api/graph/status/route.ts | 5 +++++ components/graph-view.tsx | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/api/graph/status/route.ts b/app/api/graph/status/route.ts index cedf82f..82dbde3 100644 --- a/app/api/graph/status/route.ts +++ b/app/api/graph/status/route.ts @@ -13,6 +13,11 @@ export const dynamic = "force-dynamic"; export async function GET() { const runs = extractionStatuses().map((run) => ({ + // Unique row id (the registry job key: source id, or "src#i" for + // chunk jobs) — one source's rows share chatId, and React keys on + // it would collide. chatId stays the SOURCE id: reveal/cost + // tracking in the view is source-scoped by design. + id: run.part ? `${run.chatId}#${run.part.i}` : run.chatId, chatId: run.chatId, // The run's own label (the file path for notes imports) wins; chat // runs fall back to the chat store's title. Chunk jobs append their diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 5f7297a..a2b2a28 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -1067,7 +1067,10 @@ export function GraphView() { // merge is its own graph change and must surface immediately, not wait // for the whole feed to drain. A redundant fetch on aborted runs is // free — the same doctrine as the deletion-refresh path below. - const [pipelineRuns, setPipelineRuns] = useState<{ chatId: string; title: string; phase: string; startedAt: number }[]>([]); + // Row ids are the registry JOB keys (source id, or "src#i" for chunk + // jobs) — unique per pill; chatId stays the SOURCE id (reveal/cost + // tracking is source-scoped). + const [pipelineRuns, setPipelineRuns] = useState<{ id: string; chatId: string; title: string; phase: string; startedAt: number }[]>([]); useEffect(() => { let cancelled = false; let prevIds = new Set(); @@ -1552,7 +1555,7 @@ export function GraphView() { {costs && costs.runs.length > 0 && } {pipelineRuns.map((run) => (