diff --git a/CHANGELOG.md b/CHANGELOG.md index 7935da23..a0b2acc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) - 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/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/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; +} 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" } }