From e96a456d2318eaea5206ce3e14839980af2a6da8 Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Tue, 21 Jul 2026 18:30:38 +0530 Subject: [PATCH 1/3] fix(dashboard): give log-viewer rows a stable unique key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rows in the virtualized session log were keyed `entry.uuid || entry.timestamp`. `baseEntry` leaves `uuid` as "" for every CLI that writes no per-record uuid (Codex, Copilot, Cursor, Pi), so the key degraded to the timestamp — which those CLIs reuse freely: one 771-record Codex session carries 93 timestamps shared by 2-5 records each. Duplicate React keys break reconciliation, and in a virtualized list the fallout is worse than a warning: orphaned DOM nodes are stranded at their old transform and never removed, so an unrelated message paints over the one you are reading and scrolling away and back does not clear it. Reproduced against a real Codex transcript — after one segment collapse, data-index 2 had two nodes and data-index 3 had three, one of them a message from 58 minutes later drawn across the two beneath it. Entries now take their identity from `buildEntryKeys` (lib/entry-keys.ts), which prefers the real uuid and disambiguates collisions by occurrence order. The same map is threaded into `getSegmentId` — queue-operation dividers are uuid-less even on Claude, so two sharing a millisecond collapsed into one segment — and into the virtualizer as `getItemKey`, which was absent entirely: TanStack defaults to keying its size and element caches by index, so collapsing a segment shifted every index and replayed one entry's cached height onto a different entry, misplacing rows independently of the key bug. The nested subagent list carried the same duplicate key. `buildEntryKeys` lives in its own dependency-free module rather than in log-entries.ts on purpose: that module reaches fs/promises through its project-resolution imports, so a *value* import of it from a client component pulls node:fs into the browser bundle and 500s the session page. Client code may only import types from there. Also fixes the Policies filter placeholder, which rendered the literal text `…` — a JSX attribute string is not a JS string literal, so the escape was painted verbatim beside a sibling input that had it right. Latent since before the open-source release but unreachable until #226 routed Codex transcripts into the Claude viewer. Known gap left open: EntryRow still anchors on entry.uuid, so #entry-… deep links stay inert for uuid-less transcripts. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 + __tests__/lib/build-entry-keys.test.ts | 94 +++++++++++++++++++++++++ app/components/log-viewer/entry-row.tsx | 11 ++- app/components/raw-log-viewer.tsx | 48 +++++++++---- app/policies/hooks-client.tsx | 2 +- lib/entry-keys.ts | 36 ++++++++++ 6 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 __tests__/lib/build-entry-keys.test.ts create mode 100644 lib/entry-keys.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7935da23..995f586f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## 0.0.14-beta.3 — 2026-07-20 ### Fixes +- Stop the session log viewer stacking messages on top of each other. Rows in the virtualized log list were keyed `entry.uuid || entry.timestamp`, and `baseEntry` leaves `uuid` as `""` for every CLI that writes no per-record uuid — Codex, Copilot, Cursor and Pi — so the key silently degraded to the timestamp, which those CLIs reuse freely: one 771-record Codex session carries 93 timestamps shared by 2-5 records each. Duplicate React keys break reconciliation, and in a virtualized list the fallout is worse than a warning — orphaned DOM nodes are stranded at their old `transform` and never removed, so an unrelated message paints over the one you are reading and scrolling away and back does not clear it. Reproduced against a real Codex transcript: after one segment collapse, `data-index` 2 had two nodes and `data-index` 3 had three, one of them a message from 58 minutes later drawn across the two beneath it. Entries now get their identity from a new `buildEntryKeys` (`lib/entry-keys.ts`), which prefers the real uuid and disambiguates collisions by occurrence order, so keys are unique and stable across rebuilds. The same map is threaded into `getSegmentId` — queue-operation dividers are uuid-less even on Claude, so two dividers sharing a millisecond collapsed into one segment — and into the virtualizer as `getItemKey`, which was absent: TanStack defaults to keying its size and element caches by *index*, so collapsing a segment shifted every index and replayed one entry's cached height onto a different entry, misplacing rows independently of the key bug. The nested subagent list had the same duplicate key and is fixed alongside. `buildEntryKeys` deliberately lives in its own dependency-free module rather than in `log-entries.ts`, because that module reaches `fs/promises` through its project-resolution imports and a *value* import of it from a client component pulls `node:fs` into the browser bundle and 500s the session page — client code may only import types from there. This was latent since before the open-source release but unreachable until #226 routed Codex transcripts into the Claude viewer; #292 fixed a different misalignment in the same component (stale `scrollMargin`), which is why the remaining symptom presented intermittently rather than on every load. Claude sessions are effectively unaffected — one colliding key in a 4113-record session — but were exposed through uuid-less `file-history-snapshot` entries. Known gap left open: `EntryRow` still anchors on `entry.uuid`, so `#entry-…` deep links and the copy-link button remain inert for uuid-less transcripts. (#TBD) +- Render the policy filter's placeholder as an ellipsis instead of the literal text `…`. The Policies activity bar wrote `placeholder="filter by policy…"`, but a JSX attribute string is not a JavaScript string literal — `…` is not an escape sequence there, so the five characters were painted verbatim on screen, immediately beside the session filter next to it that had it right (`filter by session…`). Caught by looking at the rendered page rather than the source. (#TBD) - Fix the npm `publish` workflow's post-publish version bump being rejected with `GH013` when it pushed to `main`. The "Bump version for next development cycle" step pushed with the default `GITHUB_TOKEN`, which is not a bypass actor on the org-level `failproofai-rules` ruleset (pull request + 1 review required on `main`), so the automated `chore: bump version` commit was declined ("Changes must be made through a pull request") and every release left `package.json` un-bumped. The step now mints a token for the version-bot GitHub App — the same bypass actor the `bump-platform-submodule` workflow already uses — via `actions/create-github-app-token`, persists it through `actions/checkout`, and pushes as it. (#577) - Reconcile `main`'s `package.json` version to `0.0.14-beta.3`. The post-publish auto-bump had been failing to push for several releases (the fix above), so `main` drifted to `0.0.14-beta.1` while npm published through `0.0.14-beta.2`; this sets it to the next development version the now-fixed automation carries forward. (#577) diff --git a/__tests__/lib/build-entry-keys.test.ts b/__tests__/lib/build-entry-keys.test.ts new file mode 100644 index 00000000..bf56a71c --- /dev/null +++ b/__tests__/lib/build-entry-keys.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest"; +import { buildEntryKeys } from "@/lib/entry-keys"; +import type { LogEntry } from "@/lib/log-entries"; + +/** + * Regression coverage for the virtualized log viewer's row identity. + * + * Duplicate React keys strand orphaned DOM nodes on top of live rows in the + * virtualized list (a Codex session with 93 colliding timestamps rendered + * three overlapping copies of index 3). Every entry must get a unique key, + * and that key must be stable across rebuilds. + */ + +const entry = (over: Partial = {}): LogEntry => + ({ + type: "user", + _source: "session", + uuid: "", + parentUuid: null, + timestamp: "2026-05-15T11:57:43.872Z", + timestampMs: 1778846263872, + timestampFormatted: "May 15, 2026, 5:27:43 PM.872", + message: { role: "user", content: "hi" }, + ...over, + }) as LogEntry; + +describe("lib/log-entries: buildEntryKeys", () => { + it("gives uuid-less entries sharing a timestamp distinct keys", () => { + // The exact Codex shape: no uuid, identical timestamps. + const entries = [entry(), entry(), entry()]; + + const keys = buildEntryKeys(entries); + const values = entries.map((e) => keys.get(e)); + + expect(new Set(values).size).toBe(3); + expect(values.every((v) => typeof v === "string" && v.length > 0)).toBe(true); + }); + + it("keeps every key unique across a run of colliding timestamps", () => { + const entries = Array.from({ length: 50 }, (_, i) => + entry({ timestamp: i % 2 === 0 ? "2026-05-15T11:57:43.872Z" : "2026-05-15T11:57:52.077Z" }), + ); + + const keys = buildEntryKeys(entries); + + expect(new Set(entries.map((e) => keys.get(e))).size).toBe(50); + }); + + it("prefers a real uuid when the transcript supplies one", () => { + const a = entry({ uuid: "uuid-a" }); + const b = entry({ uuid: "uuid-b" }); + + const keys = buildEntryKeys([a, b]); + + expect(keys.get(a)).toBe("uuid-a"); + expect(keys.get(b)).toBe("uuid-b"); + }); + + it("still disambiguates if a transcript repeats a uuid", () => { + const a = entry({ uuid: "dupe" }); + const b = entry({ uuid: "dupe" }); + + const keys = buildEntryKeys([a, b]); + + expect(keys.get(a)).not.toBe(keys.get(b)); + }); + + it("does not collide across sources that share a timestamp", () => { + const a = entry({ _source: "session" }); + const b = entry({ _source: "agent-1" }); + + const keys = buildEntryKeys([a, b]); + + expect(keys.get(a)).not.toBe(keys.get(b)); + }); + + it("assigns stable keys across rebuilds of the same list", () => { + // The virtualizer caches measured heights by key, so an unstable key would + // replay one entry's height onto another after a collapse/expand. + const entries = [entry(), entry(), entry({ uuid: "x" })]; + + const first = buildEntryKeys(entries); + const second = buildEntryKeys(entries); + + for (const e of entries) { + expect(second.get(e)).toBe(first.get(e)); + } + }); + + it("handles an empty list", () => { + expect(buildEntryKeys([]).size).toBe(0); + }); +}); diff --git a/app/components/log-viewer/entry-row.tsx b/app/components/log-viewer/entry-row.tsx index f00196da..3ff01863 100644 --- a/app/components/log-viewer/entry-row.tsx +++ b/app/components/log-viewer/entry-row.tsx @@ -1,6 +1,7 @@ -import React, { useState, useCallback, useEffect } from "react"; +import React, { useState, useCallback, useEffect, useMemo } from "react"; import { Workflow, ChevronRight } from "lucide-react"; import type { LogEntry, ToolUseBlock } from "@/lib/log-entries"; +import { buildEntryKeys } from "@/lib/entry-keys"; import { cn } from "@/lib/utils"; import { ENTRY_BORDER_COLORS } from "./constants"; import { TypeBadge } from "./type-badge"; @@ -39,6 +40,10 @@ export function SubagentToolCard({ block, subagentEntries, projectName, sessionI const hasEntries = subagentEntries && subagentEntries.length > 0; + // Same stable-key treatment as the top-level list — subagent transcripts from + // uuid-less CLIs collide on timestamp just as readily. + const subagentKeys = useMemo(() => buildEntryKeys(subagentEntries ?? []), [subagentEntries]); + return (
{/* Header */} @@ -94,12 +99,12 @@ export function SubagentToolCard({ block, subagentEntries, projectName, sessionI {subagentEntries && subagentEntries.map((entry) => entry.type === "queue-operation" ? ( ) : ( ; -function getSegmentId(entry: QueueOperationEntry): string { - return `${entry.uuid}-${entry.timestampMs}`; +// Segment ids come from the same stable-key map as the rows, so uuid-less +// transcripts (Codex, Copilot, Cursor, Pi) can't collapse two dividers that +// happen to share a millisecond into one segment. +function getSegmentId(entry: QueueOperationEntry, keys: Map): string { + return keys.get(entry) ?? `${entry.uuid}-${entry.timestampMs}`; } /** @@ -132,7 +136,7 @@ function getSegmentId(entry: QueueOperationEntry): string { * to the count of non-queue-operation entries in its segment (entries after * it until the next queue-operation or end of list). */ -function computeSegments(entries: LogEntry[]): Map { +function computeSegments(entries: LogEntry[], keys: Map): Map { const segments = new Map(); let currentId: string | null = null; let count = 0; @@ -142,7 +146,7 @@ function computeSegments(entries: LogEntry[]): Map { if (currentId !== null) { segments.set(currentId, count); } - currentId = getSegmentId(entry); + currentId = getSegmentId(entry, keys); count = 0; } else if (currentId !== null) { count++; @@ -154,11 +158,11 @@ function computeSegments(entries: LogEntry[]): Map { return segments; } -function filterVisibleEntries(entries: LogEntry[], collapsedSessions: Set): LogEntry[] { +function filterVisibleEntries(entries: LogEntry[], collapsedSessions: Set, keys: Map): LogEntry[] { let currentCollapsed = false; return entries.filter((entry) => { if (entry.type === "queue-operation") { - currentCollapsed = collapsedSessions.has(getSegmentId(entry as QueueOperationEntry)); + currentCollapsed = collapsedSessions.has(getSegmentId(entry as QueueOperationEntry, keys)); return true; // dividers are always visible } return !currentCollapsed; @@ -234,11 +238,16 @@ function VirtualizedEntryList({ entries, entriesBySource, projectName, sessionId }; }, []); - const segments = useMemo(() => computeSegments(entries), [entries]); + // One identity per entry, shared by the React key, the segment ids and the + // virtualizer's measurement cache — all three must agree or rows get + // measured against the wrong entry. + const entryKeys = useMemo(() => buildEntryKeys(entries), [entries]); + + const segments = useMemo(() => computeSegments(entries, entryKeys), [entries, entryKeys]); const visibleEntries = useMemo( - () => filterVisibleEntries(entries, collapsedSessions), - [entries, collapsedSessions], + () => filterVisibleEntries(entries, collapsedSessions, entryKeys), + [entries, collapsedSessions, entryKeys], ); // UUID-to-index map for visible entries @@ -303,14 +312,14 @@ function VirtualizedEntryList({ entries, entriesBySource, projectName, sessionId let currentSegmentId: string | null = null; for (const entry of entries) { if (entry.type === "queue-operation") { - currentSegmentId = getSegmentId(entry); + currentSegmentId = getSegmentId(entry, entryKeys); } else if (entry.uuid === lookupUuid && currentSegmentId) { return currentSegmentId; } } } return null; - }, [highlightedUuid, parentUuidForSubagent, entries, uuidToIndex]); + }, [highlightedUuid, parentUuidForSubagent, entries, uuidToIndex, entryKeys]); useEffect(() => { if (!segmentToExpand) return; @@ -337,9 +346,18 @@ function VirtualizedEntryList({ entries, entriesBySource, projectName, sessionId }); }, []); + // getItemKey must match the React key. Without it the virtualizer defaults to + // keying its size/element caches by index, so collapsing a segment shifts + // every index and replays one entry's cached height onto a different entry. + const getItemKey = useCallback( + (index: number) => entryKeys.get(visibleEntries[index]) ?? index, + [entryKeys, visibleEntries], + ); + const virtualizer = useWindowVirtualizer({ count: visibleEntries.length, estimateSize: (index) => estimateSize(visibleEntries[index]), + getItemKey, overscan: 5, scrollMargin, }); @@ -370,7 +388,7 @@ function VirtualizedEntryList({ entries, entriesBySource, projectName, sessionId const isTarget = !!highlightedUuid && entry.uuid === (parentUuidForSubagent ?? highlightedUuid); return (
handleToggleSegment(getSegmentId(entry))} + isCollapsed={collapsedSessions.has(getSegmentId(entry, entryKeys))} + entryCount={segments.get(getSegmentId(entry, entryKeys)) ?? 0} + onToggle={() => handleToggleSegment(getSegmentId(entry, entryKeys))} /> ) : ( setFilterPolicy(e.target.value)} - placeholder="filter by policy\u2026" + placeholder="filter by policy…" className="filter-input" />
diff --git a/lib/entry-keys.ts b/lib/entry-keys.ts new file mode 100644 index 00000000..72a401f1 --- /dev/null +++ b/lib/entry-keys.ts @@ -0,0 +1,36 @@ +/** + * Stable per-entry identity for the log viewer. + * + * Deliberately standalone and dependency-free: `lib/log-entries.ts` reaches + * fs/promises through its project-resolution imports, so a *value* import of + * it from a client component drags node:fs into the browser bundle and the + * session page 500s. Client code may only import types from there. + */ +import type { LogEntry } from "./log-entries"; + +/** + * Assigns every entry a unique, render-stable identity. + * + * Not every CLI writes a per-record `uuid` — Codex, Copilot, Cursor and Pi + * transcripts have none, so `baseEntry` leaves it "". Keying rows off + * `uuid || timestamp` therefore collapses to the timestamp, which those CLIs + * reuse freely (one 771-record Codex session: 93 timestamps shared by 2-5 + * records each). Duplicate React keys break reconciliation, and in the + * virtualized log list that strands orphaned DOM nodes stacked on top of live + * rows — they keep their old transform and never recover (#292 follow-up). + * + * Collisions are disambiguated by occurrence order, so keys stay stable for as + * long as the parsed entry list is — which is what React and the virtualizer's + * measurement cache both require. + */ +export function buildEntryKeys(entries: LogEntry[]): Map { + const keys = new Map(); + const seen = new Map(); + for (const entry of entries) { + const base = entry.uuid || `${entry._source}:${entry.timestamp}`; + const n = seen.get(base) ?? 0; + seen.set(base, n + 1); + keys.set(entry, n === 0 ? base : `${base}#${n}`); + } + return keys; +} From 478795f93c59b7646cfc286fb67f360a7902985f Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Tue, 21 Jul 2026 18:31:20 +0530 Subject: [PATCH 2/3] docs: fill in PR number in changelog entries Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 995f586f..6ac40eb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,8 @@ ## 0.0.14-beta.3 — 2026-07-20 ### Fixes -- Stop the session log viewer stacking messages on top of each other. Rows in the virtualized log list were keyed `entry.uuid || entry.timestamp`, and `baseEntry` leaves `uuid` as `""` for every CLI that writes no per-record uuid — Codex, Copilot, Cursor and Pi — so the key silently degraded to the timestamp, which those CLIs reuse freely: one 771-record Codex session carries 93 timestamps shared by 2-5 records each. Duplicate React keys break reconciliation, and in a virtualized list the fallout is worse than a warning — orphaned DOM nodes are stranded at their old `transform` and never removed, so an unrelated message paints over the one you are reading and scrolling away and back does not clear it. Reproduced against a real Codex transcript: after one segment collapse, `data-index` 2 had two nodes and `data-index` 3 had three, one of them a message from 58 minutes later drawn across the two beneath it. Entries now get their identity from a new `buildEntryKeys` (`lib/entry-keys.ts`), which prefers the real uuid and disambiguates collisions by occurrence order, so keys are unique and stable across rebuilds. The same map is threaded into `getSegmentId` — queue-operation dividers are uuid-less even on Claude, so two dividers sharing a millisecond collapsed into one segment — and into the virtualizer as `getItemKey`, which was absent: TanStack defaults to keying its size and element caches by *index*, so collapsing a segment shifted every index and replayed one entry's cached height onto a different entry, misplacing rows independently of the key bug. The nested subagent list had the same duplicate key and is fixed alongside. `buildEntryKeys` deliberately lives in its own dependency-free module rather than in `log-entries.ts`, because that module reaches `fs/promises` through its project-resolution imports and a *value* import of it from a client component pulls `node:fs` into the browser bundle and 500s the session page — client code may only import types from there. This was latent since before the open-source release but unreachable until #226 routed Codex transcripts into the Claude viewer; #292 fixed a different misalignment in the same component (stale `scrollMargin`), which is why the remaining symptom presented intermittently rather than on every load. Claude sessions are effectively unaffected — one colliding key in a 4113-record session — but were exposed through uuid-less `file-history-snapshot` entries. Known gap left open: `EntryRow` still anchors on `entry.uuid`, so `#entry-…` deep links and the copy-link button remain inert for uuid-less transcripts. (#TBD) -- Render the policy filter's placeholder as an ellipsis instead of the literal text `…`. The Policies activity bar wrote `placeholder="filter by policy…"`, but a JSX attribute string is not a JavaScript string literal — `…` is not an escape sequence there, so the five characters were painted verbatim on screen, immediately beside the session filter next to it that had it right (`filter by session…`). Caught by looking at the rendered page rather than the source. (#TBD) +- Stop the session log viewer stacking messages on top of each other. Rows in the virtualized log list were keyed `entry.uuid || entry.timestamp`, and `baseEntry` leaves `uuid` as `""` for every CLI that writes no per-record uuid — Codex, Copilot, Cursor and Pi — so the key silently degraded to the timestamp, which those CLIs reuse freely: one 771-record Codex session carries 93 timestamps shared by 2-5 records each. Duplicate React keys break reconciliation, and in a virtualized list the fallout is worse than a warning — orphaned DOM nodes are stranded at their old `transform` and never removed, so an unrelated message paints over the one you are reading and scrolling away and back does not clear it. Reproduced against a real Codex transcript: after one segment collapse, `data-index` 2 had two nodes and `data-index` 3 had three, one of them a message from 58 minutes later drawn across the two beneath it. Entries now get their identity from a new `buildEntryKeys` (`lib/entry-keys.ts`), which prefers the real uuid and disambiguates collisions by occurrence order, so keys are unique and stable across rebuilds. The same map is threaded into `getSegmentId` — queue-operation dividers are uuid-less even on Claude, so two dividers sharing a millisecond collapsed into one segment — and into the virtualizer as `getItemKey`, which was absent: TanStack defaults to keying its size and element caches by *index*, so collapsing a segment shifted every index and replayed one entry's cached height onto a different entry, misplacing rows independently of the key bug. The nested subagent list had the same duplicate key and is fixed alongside. `buildEntryKeys` deliberately lives in its own dependency-free module rather than in `log-entries.ts`, because that module reaches `fs/promises` through its project-resolution imports and a *value* import of it from a client component pulls `node:fs` into the browser bundle and 500s the session page — client code may only import types from there. This was latent since before the open-source release but unreachable until #226 routed Codex transcripts into the Claude viewer; #292 fixed a different misalignment in the same component (stale `scrollMargin`), which is why the remaining symptom presented intermittently rather than on every load. Claude sessions are effectively unaffected — one colliding key in a 4113-record session — but were exposed through uuid-less `file-history-snapshot` entries. Known gap left open: `EntryRow` still anchors on `entry.uuid`, so `#entry-…` deep links and the copy-link button remain inert for uuid-less transcripts. (#587) +- Render the policy filter's placeholder as an ellipsis instead of the literal text `…`. The Policies activity bar wrote `placeholder="filter by policy…"`, but a JSX attribute string is not a JavaScript string literal — `…` is not an escape sequence there, so the five characters were painted verbatim on screen, immediately beside the session filter next to it that had it right (`filter by session…`). Caught by looking at the rendered page rather than the source. (#587) - Fix the npm `publish` workflow's post-publish version bump being rejected with `GH013` when it pushed to `main`. The "Bump version for next development cycle" step pushed with the default `GITHUB_TOKEN`, which is not a bypass actor on the org-level `failproofai-rules` ruleset (pull request + 1 review required on `main`), so the automated `chore: bump version` commit was declined ("Changes must be made through a pull request") and every release left `package.json` un-bumped. The step now mints a token for the version-bot GitHub App — the same bypass actor the `bump-platform-submodule` workflow already uses — via `actions/create-github-app-token`, persists it through `actions/checkout`, and pushes as it. (#577) - Reconcile `main`'s `package.json` version to `0.0.14-beta.3`. The post-publish auto-bump had been failing to push for several releases (the fix above), so `main` drifted to `0.0.14-beta.1` while npm published through `0.0.14-beta.2`; this sets it to the next development version the now-fixed automation carries forward. (#577) From 84a6d875034b05095bf3dc4b10e1f8eabc2f1616 Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Tue, 21 Jul 2026 18:54:10 +0530 Subject: [PATCH 3/3] fix(deps): pin brace-expansion to 5.0.7 to clear GHSA-3jxr-9vmj-r5cp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The advisory published after main's last green scan, so every branch went red at once with no dependency change of its own — re-running main's last Supply Chain scan on its unchanged commit reproduced the failure. Both affected copies were transitive: 5.0.6 via minimatch@10, and three copies of 1.1.15 via the minimatch@3 that eslint-plugin-import, -jsx-a11y and -react still pull. `bun update brace-expansion` is the wrong tool: it adds the package as a direct dependency and lifts only the top-level copy, leaving the nested 5.0.6 and all three 1.1.15 copies vulnerable. Bun also ignores npm's range-keyed (`brace-expansion@^1.1.7`) and yarn's nested-path (`parent/child/pkg`) override forms — resolving nothing, silently — so the two majors cannot be pinned separately. Only the plain-key `overrides` form takes effect, as the existing postcss/vite/undici pins show. A single pin therefore also hands v5 to minimatch@3, which declares ^1.1.7. That is safe in practice: the package's entire surface is one `expand()` function whose signature is unchanged across these majors, and eslint exercises that path directly — lint runs clean with the same five pre-existing warnings. Verified with CI's own scanner image (ghcr.io/google/osv-scanner-action:v2.3.8) against the updated lockfile: "No issues found", exit 0. osv-scanner.toml keeps its zero ignored vulnerabilities. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + bun.lock | 21 ++------------------- package.json | 3 ++- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac40eb8..a0b2acc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.0.14-beta.3 — 2026-07-20 ### Fixes +- Pin `brace-expansion` to 5.0.7, clearing the two High-severity GHSA-3jxr-9vmj-r5cp findings that turned the Supply Chain gate red. The advisory published after the last green scan on `main`, so every branch went red at once with no dependency change of its own — re-running `main`'s last scan on its unchanged commit reproduced the failure. Both affected copies were transitive: `brace-expansion@5.0.6` via `minimatch@10`, and three copies of `1.1.15` via the `minimatch@3` that `eslint-plugin-import`, `-jsx-a11y` and `-react` still pull. `bun update brace-expansion` is the wrong tool here — it adds the package as a *direct* dependency and only lifts the top-level copy, leaving the nested `5.0.6` and all three `1.1.15` copies in place. Bun also ignores npm's range-keyed (`brace-expansion@^1.1.7`) and yarn's nested-path (`parent/child/pkg`) override forms, silently resolving nothing, so the two majors cannot be pinned separately; only the plain-key `overrides` form takes effect (as the existing `postcss`/`vite`/`undici` pins show). A single pin to 5.0.7 therefore also hands v5 to `minimatch@3`, which declares `^1.1.7` — safe in practice because the package's whole surface is one `expand()` function whose signature is unchanged across those majors, and `eslint` exercises exactly that path: lint runs clean with the same five pre-existing warnings. Verified by running CI's own scanner image (`ghcr.io/google/osv-scanner-action:v2.3.8`) against the updated lockfile locally: `No issues found`, exit 0. `osv-scanner.toml` keeps its zero ignored vulnerabilities. (#587) - Stop the session log viewer stacking messages on top of each other. Rows in the virtualized log list were keyed `entry.uuid || entry.timestamp`, and `baseEntry` leaves `uuid` as `""` for every CLI that writes no per-record uuid — Codex, Copilot, Cursor and Pi — so the key silently degraded to the timestamp, which those CLIs reuse freely: one 771-record Codex session carries 93 timestamps shared by 2-5 records each. Duplicate React keys break reconciliation, and in a virtualized list the fallout is worse than a warning — orphaned DOM nodes are stranded at their old `transform` and never removed, so an unrelated message paints over the one you are reading and scrolling away and back does not clear it. Reproduced against a real Codex transcript: after one segment collapse, `data-index` 2 had two nodes and `data-index` 3 had three, one of them a message from 58 minutes later drawn across the two beneath it. Entries now get their identity from a new `buildEntryKeys` (`lib/entry-keys.ts`), which prefers the real uuid and disambiguates collisions by occurrence order, so keys are unique and stable across rebuilds. The same map is threaded into `getSegmentId` — queue-operation dividers are uuid-less even on Claude, so two dividers sharing a millisecond collapsed into one segment — and into the virtualizer as `getItemKey`, which was absent: TanStack defaults to keying its size and element caches by *index*, so collapsing a segment shifted every index and replayed one entry's cached height onto a different entry, misplacing rows independently of the key bug. The nested subagent list had the same duplicate key and is fixed alongside. `buildEntryKeys` deliberately lives in its own dependency-free module rather than in `log-entries.ts`, because that module reaches `fs/promises` through its project-resolution imports and a *value* import of it from a client component pulls `node:fs` into the browser bundle and 500s the session page — client code may only import types from there. This was latent since before the open-source release but unreachable until #226 routed Codex transcripts into the Claude viewer; #292 fixed a different misalignment in the same component (stale `scrollMargin`), which is why the remaining symptom presented intermittently rather than on every load. Claude sessions are effectively unaffected — one colliding key in a 4113-record session — but were exposed through uuid-less `file-history-snapshot` entries. Known gap left open: `EntryRow` still anchors on `entry.uuid`, so `#entry-…` deep links and the copy-link button remain inert for uuid-less transcripts. (#587) - Render the policy filter's placeholder as an ellipsis instead of the literal text `…`. The Policies activity bar wrote `placeholder="filter by policy…"`, but a JSX attribute string is not a JavaScript string literal — `…` is not an escape sequence there, so the five characters were painted verbatim on screen, immediately beside the session filter next to it that had it right (`filter by session…`). Caught by looking at the rendered page rather than the source. (#587) - Fix the npm `publish` workflow's post-publish version bump being rejected with `GH013` when it pushed to `main`. The "Bump version for next development cycle" step pushed with the default `GITHUB_TOKEN`, which is not a bypass actor on the org-level `failproofai-rules` ruleset (pull request + 1 review required on `main`), so the automated `chore: bump version` commit was declined ("Changes must be made through a pull request") and every release left `package.json` un-bumped. The step now mints a token for the version-bot GitHub App — the same bypass actor the `bump-platform-submodule` workflow already uses — via `actions/create-github-app-token`, persists it through `actions/checkout`, and pushes as it. (#577) diff --git a/bun.lock b/bun.lock index fb9a44bf..5e9d1441 100644 --- a/bun.lock +++ b/bun.lock @@ -39,6 +39,7 @@ }, }, "overrides": { + "brace-expansion": "5.0.7", "eslint-plugin-react-hooks": "7.0.1", "postcss": "8.5.14", "undici": "7.28.0", @@ -489,7 +490,7 @@ "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], - "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + "brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -523,8 +524,6 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -1301,8 +1300,6 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@tailwindcss/node/tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], @@ -1315,8 +1312,6 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@tailwindcss/postcss/tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], - "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], @@ -1352,17 +1347,5 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "sharp/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - - "eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], - - "eslint-plugin-jsx-a11y/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], - - "eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], - - "eslint-plugin-import/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "eslint-plugin-jsx-a11y/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "eslint-plugin-react/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/package.json b/package.json index 710839fb..41754b54 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "postcss": "8.5.14", "eslint-plugin-react-hooks": "7.0.1", "vite": "8.0.16", - "undici": "7.28.0" + "undici": "7.28.0", + "brace-expansion": "5.0.7" } }