mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
fix(history): size an agent run by files touched, not by ops (BEA-39) (#87)
The run card header counted ops, so a path rewritten five times inflated the one number a reader uses to size a run: 14 ops across 10 paths read "14 files". It now counts distinct paths and keeps the word "files"; every op is still a row inside the card. The same count decided whether a run got a card at all, so a run that hit one path five times drew a card claiming "5 files". Counting by file demotes it to bare rows, each still showing its session note. groupRuns and the Run/Item types move to src/lib/runs.ts (pure, no React) so node's test runner can import them — a .tsx with JSX can't be. The grouping key, ordering, time span, who and device are unchanged, and run.idx still addresses the flat feed so diffs and restore shas are unaffected. The key's NUL separator moves across as an explicit "\0" — it was a raw NUL byte in the source, which is also why git saw the old HistoryView.tsx as binary. Deviation from the reviewed plan: flipping the threshold in place would have dropped rows. Only a run's first entry was ever pushed to the output list, so a demoted 5-op run would have rendered one row, not five. groupRuns now builds runs first and emits items in a second pass over the feed, which keeps demoted rows at their own newest-first positions. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
01f33c9fe7
commit
3d0eee0254
@@ -78,6 +78,7 @@ classDiagram
|
||||
|
||||
class lib {
|
||||
+diff.ts splitLines lcsDiff diffText
|
||||
+runs.ts groupRuns runFileCount
|
||||
+heat.ts heatFor heatTotal heatText heatLevel hotPathSplit
|
||||
+sniff.ts sniffBytes BlobText MAX_BYTES
|
||||
+utils.ts
|
||||
@@ -93,7 +94,7 @@ classDiagram
|
||||
Browser --> components
|
||||
HubApp --> components
|
||||
components --> nav : linkProps navigate
|
||||
components --> lib : diffText hotPathSplit
|
||||
components --> lib : diffText groupRuns hotPathSplit
|
||||
hooks --> lib : re-exports heat.ts, sniffBytes
|
||||
hooks --> api
|
||||
Browser --> hooks
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { HistoryEntry } from "../api/types";
|
||||
import { HistoryRow, NoteText, type RemoveAction, type RestoreAction } from "./HistoryRow";
|
||||
import { Icon } from "./shell";
|
||||
import { whoChanged } from "../util";
|
||||
import { groupRuns, runFileCount, type Run } from "../lib/runs";
|
||||
|
||||
/* ---- history ----
|
||||
Every change ever made, straight from the journals: who (account), when,
|
||||
@@ -104,43 +105,6 @@ export function HistoryView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
// One run: the entries that share a (note, device), with the index each came
|
||||
// from so diff lookups still address the flat feed.
|
||||
type Run = { note: string; entries: HistoryEntry[]; idx: number[] };
|
||||
type Item = { run?: Run; i: number };
|
||||
|
||||
/* Group key = note + device id, exact match. Deliberately simple, and it
|
||||
guarantees a group never spans two journals — one writer, one op range —
|
||||
which is what a later run-wide restore needs. Two devices that happen to
|
||||
write the same note are two runs. Grouping spans the whole window rather
|
||||
than only consecutive rows, so a run whose ops interleave with another
|
||||
device's still reads as one thing; each group sits where its newest
|
||||
member did, keeping the feed newest-first. */
|
||||
export function groupRuns(entries: HistoryEntry[]): Item[] {
|
||||
const runs = new Map<string, Run>();
|
||||
const out: Item[] = [];
|
||||
entries.forEach((e, i) => {
|
||||
if (!e.note) {
|
||||
out.push({ i });
|
||||
return;
|
||||
}
|
||||
const key = e.note + "\0" + (e.device?.id ?? "");
|
||||
const run = runs.get(key);
|
||||
if (run) {
|
||||
run.entries.push(e);
|
||||
run.idx.push(i);
|
||||
return;
|
||||
}
|
||||
const fresh: Run = { note: e.note, entries: [e], idx: [i] };
|
||||
runs.set(key, fresh);
|
||||
out.push({ run: fresh, i });
|
||||
});
|
||||
// A run that touched one file is not worth a card: the row already shows
|
||||
// its note, and wrapping it would say the same thing twice. Grouping earns
|
||||
// its chrome from the second file on.
|
||||
return out.map((item) => (item.run && item.run.entries.length < 2 ? { i: item.i } : item));
|
||||
}
|
||||
|
||||
function RunGroup({
|
||||
run,
|
||||
onOpen,
|
||||
@@ -166,7 +130,9 @@ function RunGroup({
|
||||
const dev = [first.device.name || first.device.id, first.device.os].filter(Boolean).join(" · ");
|
||||
const times = run.entries.map((e) => new Date(e.time).getTime());
|
||||
const span = fmtSpan(Math.min(...times), Math.max(...times));
|
||||
const n = run.entries.length;
|
||||
// Distinct paths, not ops: repeat edits to one file must not inflate the
|
||||
// one number that sizes a run (BEA-39). Every op is still a row below.
|
||||
const n = runFileCount(run);
|
||||
return (
|
||||
<div className={"hrun" + (open ? " open" : "")}>
|
||||
<div className="hrun-head">
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Run with `npm test` (node's built-in runner; node ≥ 23 strips the types).
|
||||
// Excluded from tsconfig's include — it imports node: builtins, which the
|
||||
// app's DOM-only lib set does not know about.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { groupRuns, runFileCount } from "./runs.ts";
|
||||
import type { HistoryEntry } from "../api/types";
|
||||
|
||||
// A history entry, newest-first order supplied by the caller.
|
||||
const e = (path: string, note?: string, device = "mac-mini"): HistoryEntry => ({
|
||||
time: "2026-07-29T14:02:00Z",
|
||||
kind: "edit",
|
||||
path,
|
||||
note,
|
||||
device: { id: device },
|
||||
});
|
||||
|
||||
test("the repro run: 14 ops across 10 paths reads as 10 files", () => {
|
||||
const note = "claude-session-abc123";
|
||||
const entries = [
|
||||
...Array.from({ length: 5 }, () => e("ideas.md", note)), // churned five times
|
||||
e("memory.md", note), // rewritten
|
||||
...Array.from({ length: 8 }, (_, k) => e(`old/${k}.md`, note)), // deleted
|
||||
];
|
||||
const items = groupRuns(entries);
|
||||
assert.equal(items.length, 1);
|
||||
const run = items[0].run;
|
||||
assert.ok(run);
|
||||
assert.equal(runFileCount(run), 10);
|
||||
assert.equal(run.entries.length, 14); // every op still listed inside the card
|
||||
// idx addresses the flat feed — diffs and restore shas ride on this.
|
||||
assert.deepEqual(
|
||||
run.idx,
|
||||
Array.from({ length: 14 }, (_, i) => i),
|
||||
);
|
||||
assert.deepEqual(
|
||||
run.entries.map((x) => x.path),
|
||||
entries.map((x) => x.path),
|
||||
);
|
||||
});
|
||||
|
||||
test("five ops on one path are bare rows, not a card claiming five files", () => {
|
||||
const items = groupRuns(Array.from({ length: 5 }, () => e("ideas.md", "session-1")));
|
||||
assert.equal(items.length, 5);
|
||||
assert.deepEqual(items, [{ i: 0 }, { i: 1 }, { i: 2 }, { i: 3 }, { i: 4 }]);
|
||||
});
|
||||
|
||||
test("a single op with a note is a bare row", () => {
|
||||
assert.deepEqual(groupRuns([e("ideas.md", "session-1")]), [{ i: 0 }]);
|
||||
});
|
||||
|
||||
test("two paths still earn a card when one was edited repeatedly", () => {
|
||||
const note = "session-1";
|
||||
const entries = [
|
||||
e("ideas.md", note),
|
||||
e("ideas.md", note),
|
||||
e("ideas.md", note),
|
||||
e("ideas.md", note),
|
||||
e("memory.md", note),
|
||||
];
|
||||
const items = groupRuns(entries);
|
||||
assert.equal(items.length, 1);
|
||||
const run = items[0].run;
|
||||
assert.ok(run);
|
||||
assert.equal(runFileCount(run), 2);
|
||||
assert.equal(run.entries.length, 5);
|
||||
});
|
||||
|
||||
test("the grouping key is note + device, so two devices are two runs", () => {
|
||||
const note = "session-1";
|
||||
const items = groupRuns([
|
||||
e("a.md", note, "mac-mini"),
|
||||
e("b.md", note, "mac-mini"),
|
||||
e("a.md", note, "laptop"),
|
||||
e("c.md", note, "laptop"),
|
||||
]);
|
||||
assert.equal(items.length, 2);
|
||||
assert.deepEqual(
|
||||
items.map((it) => it.run?.entries.length),
|
||||
[2, 2],
|
||||
);
|
||||
assert.deepEqual(items.map((it) => it.run?.idx), [
|
||||
[0, 1],
|
||||
[2, 3],
|
||||
]);
|
||||
});
|
||||
|
||||
test("the key separator keeps a note+device pair from colliding with another", () => {
|
||||
// Two runs whose note and device concatenate to the same string under a
|
||||
// printable separator: "a b" + "c" vs "a" + "b c".
|
||||
const items = groupRuns([
|
||||
e("one.md", "a b", "c"),
|
||||
e("two.md", "a b", "c"),
|
||||
e("three.md", "a", "b c"),
|
||||
e("four.md", "a", "b c"),
|
||||
]);
|
||||
assert.equal(items.length, 2);
|
||||
assert.deepEqual(
|
||||
items.map((it) => it.run?.entries.map((x) => x.path)),
|
||||
[
|
||||
["one.md", "two.md"],
|
||||
["three.md", "four.md"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("note-less changes stay bare rows, cards sit where their newest op did", () => {
|
||||
const note = "session-1";
|
||||
const items = groupRuns([e("plain.md"), e("a.md", note), e("other.md"), e("b.md", note)]);
|
||||
assert.equal(items.length, 3);
|
||||
assert.deepEqual(items[0], { i: 0 });
|
||||
assert.equal(items[1].i, 1);
|
||||
assert.equal(items[1].run?.entries.length, 2);
|
||||
assert.deepEqual(items[2], { i: 2 });
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { HistoryEntry } from "../api/types";
|
||||
|
||||
/* ---- agent runs ----
|
||||
Pure grouping for the history feed, no React: the run card's shape and the
|
||||
one number in its header, unit-tested on node (`npm test`). */
|
||||
|
||||
// One run: the entries that share a (note, device), with the index each came
|
||||
// from so diff lookups still address the flat feed.
|
||||
export type Run = { note: string; entries: HistoryEntry[]; idx: number[] };
|
||||
export type Item = { run?: Run; i: number };
|
||||
|
||||
// How much of the project a run touched: distinct paths, not ops. A path
|
||||
// rewritten five times is one file, and this is the number a reader uses to
|
||||
// size a run before expanding it — the op count stays visible as the rows
|
||||
// inside the card.
|
||||
export function runFileCount(run: Run): number {
|
||||
return new Set(run.entries.map((e) => e.path)).size;
|
||||
}
|
||||
|
||||
/* Group key = note + device id, exact match. Deliberately simple, and it
|
||||
guarantees a group never spans two journals — one writer, one op range —
|
||||
which is what a later run-wide restore needs. Two devices that happen to
|
||||
write the same note are two runs. Grouping spans the whole window rather
|
||||
than only consecutive rows, so a run whose ops interleave with another
|
||||
device's still reads as one thing; each group sits where its newest
|
||||
member did, keeping the feed newest-first. */
|
||||
export function groupRuns(entries: HistoryEntry[]): Item[] {
|
||||
// NUL separator: it cannot occur in a note or a device id, so no pair of
|
||||
// them can collide into one key.
|
||||
const key = (e: HistoryEntry) => e.note + "\0" + (e.device?.id ?? "");
|
||||
const runs = new Map<string, Run>();
|
||||
entries.forEach((e, i) => {
|
||||
if (!e.note) return;
|
||||
const run = runs.get(key(e));
|
||||
if (run) {
|
||||
run.entries.push(e);
|
||||
run.idx.push(i);
|
||||
return;
|
||||
}
|
||||
runs.set(key(e), { note: e.note, entries: [e], idx: [i] });
|
||||
});
|
||||
// A run that touched one file is not worth a card: the row already shows
|
||||
// its note, and wrapping it would say the same thing twice. Grouping earns
|
||||
// its chrome from the second file on — counted by file, not by op, so five
|
||||
// edits to one path stay five bare rows in place of a card claiming
|
||||
// "5 files". Note-less changes were always bare rows.
|
||||
const out: Item[] = [];
|
||||
const carded = new Set<Run>();
|
||||
entries.forEach((e, i) => {
|
||||
const run = e.note ? runs.get(key(e)) : undefined;
|
||||
if (!run || runFileCount(run) < 2) {
|
||||
out.push({ i });
|
||||
return;
|
||||
}
|
||||
if (carded.has(run)) return; // its rows live inside the card
|
||||
carded.add(run);
|
||||
out.push({ run, i });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
+14
-14
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BearDrive</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-DsYnPaPb.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-COmfuzDL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B6ZMPo8E.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user