Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
## 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)
Comment thread
NiveditJain marked this conversation as resolved.
- 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)

Expand Down
94 changes: 94 additions & 0 deletions __tests__/lib/build-entry-keys.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
11 changes: 8 additions & 3 deletions app/components/log-viewer/entry-row.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
<div className="border border-border/50 rounded-lg p-3 bg-muted/10">
{/* Header */}
Expand Down Expand Up @@ -94,12 +99,12 @@ export function SubagentToolCard({ block, subagentEntries, projectName, sessionI
{subagentEntries && subagentEntries.map((entry) =>
entry.type === "queue-operation" ? (
<QueueDivider
key={entry.uuid || entry.timestamp}
key={subagentKeys.get(entry)}
entry={entry}
/>
) : (
<EntryRow
key={entry.uuid || entry.timestamp}
key={subagentKeys.get(entry)}
entry={entry}
projectName={projectName}
sessionId={sessionId}
Expand Down
48 changes: 33 additions & 15 deletions app/components/raw-log-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
import { useWindowVirtualizer } from "@tanstack/react-virtual";
import { ChevronDown, Wrench } from "lucide-react";
import type { LogEntry, ToolUseBlock } from "@/lib/log-entries";
import { buildEntryKeys } from "@/lib/entry-keys";
import { StatsBar } from "@/app/components/log-viewer/stats-bar";
import { QueueDivider } from "@/app/components/log-viewer/queue-divider";
import { EntryRow } from "@/app/components/log-viewer/entry-row";
Expand Down Expand Up @@ -123,16 +124,19 @@ function estimateSize(entry: LogEntry): number {

type QueueOperationEntry = Extract<LogEntry, { 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<LogEntry, string>): string {
return keys.get(entry) ?? `${entry.uuid}-${entry.timestampMs}`;
}

/**
* Walks the entries array and returns a Map from each queue-operation segment
* 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<string, number> {
function computeSegments(entries: LogEntry[], keys: Map<LogEntry, string>): Map<string, number> {
const segments = new Map<string, number>();
let currentId: string | null = null;
let count = 0;
Expand All @@ -142,7 +146,7 @@ function computeSegments(entries: LogEntry[]): Map<string, number> {
if (currentId !== null) {
segments.set(currentId, count);
}
currentId = getSegmentId(entry);
currentId = getSegmentId(entry, keys);
count = 0;
} else if (currentId !== null) {
count++;
Expand All @@ -154,11 +158,11 @@ function computeSegments(entries: LogEntry[]): Map<string, number> {
return segments;
}

function filterVisibleEntries(entries: LogEntry[], collapsedSessions: Set<string>): LogEntry[] {
function filterVisibleEntries(entries: LogEntry[], collapsedSessions: Set<string>, keys: Map<LogEntry, string>): 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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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,
});
Expand Down Expand Up @@ -370,7 +388,7 @@ function VirtualizedEntryList({ entries, entriesBySource, projectName, sessionId
const isTarget = !!highlightedUuid && entry.uuid === (parentUuidForSubagent ?? highlightedUuid);
return (
<div
key={entry.type === "queue-operation" ? getSegmentId(entry) : (entry.uuid || entry.timestamp)}
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
Expand All @@ -384,9 +402,9 @@ function VirtualizedEntryList({ entries, entriesBySource, projectName, sessionId
{entry.type === "queue-operation" ? (
<QueueDivider
entry={entry}
isCollapsed={collapsedSessions.has(getSegmentId(entry))}
entryCount={segments.get(getSegmentId(entry)) ?? 0}
onToggle={() => handleToggleSegment(getSegmentId(entry))}
isCollapsed={collapsedSessions.has(getSegmentId(entry, entryKeys))}
entryCount={segments.get(getSegmentId(entry, entryKeys)) ?? 0}
onToggle={() => handleToggleSegment(getSegmentId(entry, entryKeys))}
/>
) : (
<EntryRow
Expand Down
2 changes: 1 addition & 1 deletion app/policies/hooks-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,7 @@ function ActivityTab({
type="text"
value={filterPolicy}
onChange={(e) => setFilterPolicy(e.target.value)}
placeholder="filter by policy\u2026"
placeholder="filter by policy"
Comment thread
NiveditJain marked this conversation as resolved.
className="filter-input"
/>
</div>
Expand Down
Loading