refactor(web): simplify thread DNA selection identity

This commit is contained in:
Bryan Helmkamp 2026-07-24 17:23:08 -04:00
parent 110058bb4b
commit 7b82a150ad
No known key found for this signature in database
3 changed files with 237 additions and 169 deletions

View file

@ -443,7 +443,10 @@ const THREAD_CATEGORY_COLOR: Record<ThreadCategory, string> = {
export type ThreadDnaSelection =
| { kind: "single"; turnIndex: number }
| { kind: "group"; childTurnIndices: number[] };
| {
kind: "group";
childTurnIndices: readonly [number, number, ...number[]];
};
export interface ThreadDnaItem {
category: ThreadCategory;
@ -456,13 +459,15 @@ export interface ThreadDnaItem {
const INSTANT_MARKER_PX = 4;
const MIN_DURATION_PX = 3;
export function threadSelectionKey(s: ThreadDnaSelection): string {
return s.kind === "single"
? `s:${s.turnIndex}`
: `g:${s.childTurnIndices.join(",")}`;
export function threadSelectionId(selection: ThreadDnaSelection): number {
const turnIndex =
selection.kind === "single"
? selection.turnIndex
: selection.childTurnIndices[0];
return turnIndex * 2 + (selection.kind === "group" ? 1 : 0);
}
function selectionsEqual(
export function threadSelectionsEqual(
a: ThreadDnaSelection,
b: ThreadDnaSelection | null,
): boolean {
@ -504,19 +509,19 @@ export function ThreadDnaStrip({
selection: ThreadDnaSelection | null;
onSelect: (s: ThreadDnaSelection) => void;
}) {
const [hover, setHover] = useState<{ key: string; rect: DOMRect } | null>(
const [hover, setHover] = useState<{ id: number; rect: DOMRect } | null>(
null,
);
const visibleItems = useMemo(
() => sampleStripItems(items, STRIP_MAX_MARKERS, (item) =>
selectionsEqual(item.selection, selection)
threadSelectionsEqual(item.selection, selection)
),
[items, selection],
);
const visibleItemByKey = useMemo(
const visibleItemById = useMemo(
() =>
new Map(
visibleItems.map((item) => [threadSelectionKey(item.selection), item]),
visibleItems.map((item) => [threadSelectionId(item.selection), item]),
),
[visibleItems],
);
@ -542,7 +547,7 @@ export function ThreadDnaStrip({
const hoveredItem =
hover != null
? visibleItemByKey.get(hover.key) ?? null
? visibleItemById.get(hover.id) ?? null
: null;
return (
@ -552,10 +557,10 @@ export function ThreadDnaStrip({
>
<div className="relative h-full">
{visibleItems.map((item) => {
const key = threadSelectionKey(item.selection);
const id = threadSelectionId(item.selection);
const isInstant = item.durationMs <= 0;
const isSelected = selectionsEqual(item.selection, selection);
const isHovered = hover?.key === key;
const isSelected = threadSelectionsEqual(item.selection, selection);
const isHovered = hover?.id === id;
const leftPct = (item.startMs / totalMs) * 100;
const baseColor = THREAD_CATEGORY_COLOR[item.category];
@ -585,18 +590,18 @@ export function ThreadDnaStrip({
return (
<button
key={key}
key={id}
type="button"
aria-label={`${THREAD_CATEGORY_LABEL[item.category]} · ${item.label}`}
aria-pressed={isSelected}
onMouseEnter={(e) =>
setHover({
key,
id,
rect: e.currentTarget.getBoundingClientRect(),
})
}
onMouseLeave={() =>
setHover((cur) => (cur?.key === key ? null : cur))
setHover((cur) => (cur?.id === id ? null : cur))
}
onClick={() => onSelect(item.selection)}
className="absolute cursor-pointer rounded-[2px] border-0 p-0 transition-all duration-100 ease-out"

View file

@ -3,11 +3,11 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client";
import {
buildThreadDnaItems,
displayItemSelection,
EVENT_KINDS,
eventsTabLabel,
eventsToActivity,
filterDisplayItems,
filterThreadDnaItems,
formatStageModelUsageLabel,
groupConsecutiveTools,
searchableText,
@ -17,7 +17,7 @@ import {
type DisplayItem,
type EventKind,
} from "./run-stages";
import { threadSelectionKey } from "../components/event-debug";
import { threadSelectionId } from "../components/event-debug";
function envelope(seq: number, partial: Partial<EventEnvelope>): EventEnvelope {
return {
@ -30,6 +30,26 @@ function envelope(seq: number, partial: Partial<EventEnvelope>): EventEnvelope {
} as EventEnvelope;
}
function expectToolGroup(
item: DisplayItem | undefined,
): Extract<DisplayItem, { kind: "group" }> {
expect(item?.kind).toBe("group");
if (item?.kind !== "group") {
throw new Error("expected a tool group");
}
return item;
}
function expectSingleItem(
item: DisplayItem | undefined,
): Extract<DisplayItem, { kind: "single" }> {
expect(item?.kind).toBe("single");
if (item?.kind !== "single") {
throw new Error("expected a single display item");
}
return item;
}
describe("eventsToActivity", () => {
test("filters events by stage_id (verify@1 vs verify@2 do not cross-contaminate)", () => {
const events: EventEnvelope[] = [
@ -507,7 +527,12 @@ describe("groupConsecutiveTools", () => {
test("single tool turn becomes a single, not a group", () => {
const t = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell", durationMs: 100 });
expect(groupConsecutiveTools([entry(t, 0)])).toEqual([
{ kind: "single", turn: t, turnIndex: 0 },
{
kind: "single",
turn: t,
turnIndex: 0,
selection: { kind: "single", turnIndex: 0 },
},
]);
});
@ -525,6 +550,7 @@ describe("groupConsecutiveTools", () => {
{ turn: a, turnIndex: 0 },
{ turn: b, turnIndex: 1 },
],
selection: { kind: "group", childTurnIndices: [0, 1] },
},
]);
});
@ -540,15 +566,12 @@ describe("groupConsecutiveTools", () => {
const filtered = turns.map((t, i) => entry(t, i));
const result = groupConsecutiveTools(filtered);
expect(result).toHaveLength(1);
const item = result[0];
expect(item.kind).toBe("group");
if (item.kind === "group") {
expect(item.ts).toBe("2026-04-09T12:00:00Z");
// last child starts at 4s and runs 5s → ends at 9s. The summed 15s is
// not elapsed time; overlapping calls would double-count.
expect(item.durationMs).toBe(9000);
expect(item.children.map((c) => c.turnIndex)).toEqual([0, 1, 2, 3, 4]);
}
const item = expectToolGroup(result[0]);
expect(item.ts).toBe("2026-04-09T12:00:00Z");
// last child starts at 4s and runs 5s → ends at 9s. The summed 15s is
// not elapsed time; overlapping calls would double-count.
expect(item.durationMs).toBe(9000);
expect(item.children.map((c) => c.turnIndex)).toEqual([0, 1, 2, 3, 4]);
});
test("group bounds ignore array order and use the earliest start / latest end", () => {
@ -558,12 +581,10 @@ describe("groupConsecutiveTools", () => {
const early = tool({ ts: "2026-04-09T12:00:02Z", toolName: "shell", durationMs: 500 });
const result = groupConsecutiveTools([entry(late, 0), entry(early, 1)]);
expect(result).toHaveLength(1);
const item = result[0];
if (item.kind === "group") {
expect(item.ts).toBe("2026-04-09T12:00:02Z");
// earliest start 2s, latest end 5s + 4s = 9s → 7s elapsed.
expect(item.durationMs).toBe(7000);
}
const item = expectToolGroup(result[0]);
expect(item.ts).toBe("2026-04-09T12:00:02Z");
// earliest start 2s, latest end 5s + 4s = 9s → 7s elapsed.
expect(item.durationMs).toBe(7000);
});
test("parallel children collapse to their overlapping wall-clock span", () => {
@ -571,23 +592,18 @@ describe("groupConsecutiveTools", () => {
const b = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell", durationMs: 2000 });
const c = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell", durationMs: 1000 });
const result = groupConsecutiveTools([entry(a, 0), entry(b, 1), entry(c, 2)]);
const item = result[0];
if (item.kind === "group") {
// Three calls issued together: elapsed is the slowest, not the sum.
expect(item.durationMs).toBe(3000);
}
const item = expectToolGroup(result[0]);
// Three calls issued together: elapsed is the slowest, not the sum.
expect(item.durationMs).toBe(3000);
});
test("a group of unparseable timestamps falls back to zero elapsed", () => {
const a = tool({ ts: "not-a-timestamp", toolName: "shell", durationMs: 10 });
const b = tool({ ts: "also-bad", toolName: "shell", durationMs: 10 });
const result = groupConsecutiveTools([entry(a, 0), entry(b, 1)]);
const item = result[0];
expect(item.kind).toBe("group");
if (item.kind === "group") {
expect(item.ts).toBe("not-a-timestamp");
expect(item.durationMs).toBe(0);
}
const item = expectToolGroup(result[0]);
expect(item.ts).toBe("not-a-timestamp");
expect(item.durationMs).toBe(0);
});
test("a different tool between same-tool calls breaks the group boundary", () => {
@ -604,15 +620,13 @@ describe("groupConsecutiveTools", () => {
entry(e, 4),
]);
expect(result.map((r) => r.kind)).toEqual(["group", "single", "group"]);
if (result[0].kind === "group") {
expect(result[0].children.map((c) => c.turnIndex)).toEqual([0, 1]);
}
if (result[1].kind === "single") {
expect(result[1].turnIndex).toBe(2);
}
if (result[2].kind === "group") {
expect(result[2].children.map((c) => c.turnIndex)).toEqual([3, 4]);
}
expect(expectToolGroup(result[0]).children.map((c) => c.turnIndex)).toEqual([
0, 1,
]);
expect(expectSingleItem(result[1]).turnIndex).toBe(2);
expect(expectToolGroup(result[2]).children.map((c) => c.turnIndex)).toEqual([
3, 4,
]);
});
test("an errored tool call is never grouped and breaks the run", () => {
@ -627,12 +641,10 @@ describe("groupConsecutiveTools", () => {
entry(d, 3),
]);
expect(result.map((r) => r.kind)).toEqual(["single", "single", "group"]);
if (result[1].kind === "single") {
expect(result[1].turn).toBe(errored);
}
if (result[2].kind === "group") {
expect(result[2].children.map((c) => c.turnIndex)).toEqual([2, 3]);
}
expect(expectSingleItem(result[1]).turn).toBe(errored);
expect(expectToolGroup(result[2]).children.map((c) => c.turnIndex)).toEqual([
2, 3,
]);
});
test("non-tool turns flush the buffer correctly", () => {
@ -654,12 +666,10 @@ describe("groupConsecutiveTools", () => {
entry(c, 3),
]);
expect(result.map((r) => r.kind)).toEqual(["group", "single", "single"]);
if (result[0].kind === "group") {
expect(result[0].children.map((c) => c.turnIndex)).toEqual([0, 1]);
}
if (result[2].kind === "single") {
expect(result[2].turnIndex).toBe(3);
}
expect(expectToolGroup(result[0]).children.map((c) => c.turnIndex)).toEqual([
0, 1,
]);
expect(expectSingleItem(result[2]).turnIndex).toBe(3);
});
});
@ -718,6 +728,7 @@ describe("buildThreadDnaItems", () => {
kind: "single" as const,
turnIndex,
turn: { kind: "system" as const, ts, content },
selection: { kind: "single" as const, turnIndex },
};
}
@ -733,6 +744,7 @@ describe("buildThreadDnaItems", () => {
outputTokens: 0,
toolCallCount: null,
},
selection: { kind: "single" as const, turnIndex },
};
}
@ -754,6 +766,7 @@ describe("buildThreadDnaItems", () => {
isError: false,
durationMs,
},
selection: { kind: "single" as const, turnIndex },
};
}
@ -762,6 +775,7 @@ describe("buildThreadDnaItems", () => {
kind: "single" as const,
turnIndex,
turn: { kind: "steer" as const, ts, content: "do this" },
selection: { kind: "single" as const, turnIndex },
};
}
@ -861,9 +875,7 @@ describe("buildThreadDnaItems", () => {
{ turn: late, index: 0 },
{ turn: early, index: 1 },
]);
const group = grouped[0];
expect(group.kind).toBe("group");
if (group.kind !== "group") return;
const group = expectToolGroup(grouped[0]);
// span = 12s + 2s − 10s = 4s, not the summed 3s and not children[0]'s ts.
expect(group.ts).toBe("2026-04-09T12:00:10Z");
@ -951,12 +963,15 @@ describe("tool-call-only agent responses", () => {
};
const withOneTool = { ...withTools, toolCallCount: 1 };
const withoutCount = { ...withTools, toolCallCount: null };
const whitespaceOnly = { ...withTools, content: " \n\t" };
expect(turnSummary(withTools)).toBe("Requested 3 tool calls");
expect(turnSummary(withOneTool)).toBe("Requested 1 tool call");
expect(turnSummary(withoutCount)).toBe("Model response contained no text");
expect(turnSummary(whitespaceOnly)).toBe("Requested 3 tool calls");
expect(searchableText(withTools)).toContain("Requested 3 tool calls");
expect(searchableText(whitespaceOnly)).toContain("Requested 3 tool calls");
// Text-bearing responses keep searching their own content.
expect(searchableText({ ...withTools, content: "all done" })).toBe("all done");
});
@ -1052,12 +1067,8 @@ describe("tool batch boundaries", () => {
search: string,
) {
const all = buildThreadDnaItems(items, RUN_START);
const visible = new Set(
filterDisplayItems(items, kinds, search).map((item) =>
threadSelectionKey(displayItemSelection(item)),
),
);
return all.filter((item) => visible.has(threadSelectionKey(item.selection)));
const visible = filterDisplayItems(items, kinds, search);
return filterThreadDnaItems(all, visible);
}
function groupSizes(items: DisplayItem[]): (number | "single")[] {
@ -1077,6 +1088,11 @@ describe("tool batch boundaries", () => {
);
});
test("the default visibility pass reuses the grouped item list", () => {
const items = reproItems();
expect(filterDisplayItems(items, EVENT_KINDS, "")).toBe(items);
});
test("batches survive excluding Agent with the kind filter", () => {
const items = reproItems();
const withoutAgent = EVENT_KINDS.filter((k) => k !== "assistant");
@ -1095,16 +1111,13 @@ describe("tool batch boundaries", () => {
const visible = filterDisplayItems(items, EVENT_KINDS, "alpha");
expect(visible).toHaveLength(1);
const only = visible[0];
expect(only.kind).toBe("group");
if (only.kind === "group") {
// "bravo" never matched the search but stays in the group for context.
expect(only.children).toHaveLength(2);
expect(only.children.map((c) => JSON.parse(c.turn.input).command)).toEqual([
"alpha",
"bravo",
]);
}
const only = expectToolGroup(visible[0]);
// "bravo" never matched the search but stays in the group for context.
expect(only.children).toHaveLength(2);
expect(only.children.map((c) => JSON.parse(c.turn.input).command)).toEqual([
"alpha",
"bravo",
]);
});
test("DNA charges the long gaps to Agent and keeps every tool batch sub-second", () => {
@ -1157,17 +1170,16 @@ describe("tool batch boundaries", () => {
const items = reproItems();
const bars = buildThreadDnaItems(items, RUN_START);
expect(bars.map((b) => threadSelectionKey(b.selection))).toEqual(
items.map((item) => threadSelectionKey(displayItemSelection(item))),
expect(bars.map((b) => threadSelectionId(b.selection))).toEqual(
items.map((item) => threadSelectionId(item.selection)),
);
const group = items.find((item) => item.kind === "group");
expect(group).toBeDefined();
if (group?.kind === "group") {
expect(displayItemSelection(group)).toEqual({
kind: "group",
childTurnIndices: group.children.map((c) => c.turnIndex),
});
}
const group = expectToolGroup(
items.find((item) => item.kind === "group"),
);
expect(group.selection).toEqual({
kind: "group",
childTurnIndices: group.children.map((c) => c.turnIndex),
});
});
});

View file

@ -17,7 +17,8 @@ import {
EventSearchInput,
MultiSelectFilter,
ThreadDnaStrip,
threadSelectionKey,
threadSelectionId,
threadSelectionsEqual,
} from "../components/event-debug";
import {
debugCategory,
@ -60,6 +61,7 @@ import {
formatDurationMs,
formatTokenCount,
} from "../lib/format";
import { plural } from "../lib/plural";
import {
useRun,
useRunEventsList,
@ -389,22 +391,34 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn
}
type ToolTurn = Extract<TurnType, { kind: "tool" }>;
type ToolGroupChild = { turn: ToolTurn; turnIndex: number };
type ToolGroupChildren = readonly [
ToolGroupChild,
ToolGroupChild,
...ToolGroupChild[],
];
export type DisplayItem =
| { kind: "single"; turn: TurnType; turnIndex: number }
| {
kind: "single";
turn: TurnType;
turnIndex: number;
selection: Extract<ThreadDnaSelection, { kind: "single" }>;
}
| {
kind: "group";
toolName: string;
ts: string;
durationMs: number;
children: { turn: ToolTurn; turnIndex: number }[];
children: ToolGroupChildren;
selection: Extract<ThreadDnaSelection, { kind: "group" }>;
};
// A group's elapsed time is the wall-clock envelope of its children —
// earliest start to latest end — not the sum of their durations. Parallel
// calls overlap, and completion order is not always start order, so neither
// the summed duration nor the last array element is the right answer.
export function toolGroupBounds(children: { turn: ToolTurn }[]): {
function toolGroupBounds(children: ToolGroupChildren): {
ts: string;
durationMs: number;
} {
@ -429,13 +443,37 @@ export function toolGroupBounds(children: { turn: ToolTurn }[]): {
return { ts: earliestTs, durationMs: Math.max(0, latestEnd - earliestStart) };
}
export function displayItemSelection(item: DisplayItem): ThreadDnaSelection {
return item.kind === "single"
? { kind: "single", turnIndex: item.turnIndex }
: {
kind: "group",
childTurnIndices: item.children.map((child) => child.turnIndex),
};
function singleDisplayItem(turn: TurnType, turnIndex: number): DisplayItem {
return {
kind: "single",
turn,
turnIndex,
selection: { kind: "single", turnIndex },
};
}
function toolGroupDisplayItem(
first: ToolGroupChild,
second: ToolGroupChild,
rest: ToolGroupChild[],
): Extract<DisplayItem, { kind: "group" }> {
const children: ToolGroupChildren = [first, second, ...rest];
const bounds = toolGroupBounds(children);
return {
kind: "group",
toolName: first.turn.toolName,
ts: bounds.ts,
durationMs: bounds.durationMs,
children,
selection: {
kind: "group",
childTurnIndices: [
first.turnIndex,
second.turnIndex,
...rest.map((child) => child.turnIndex),
],
},
};
}
// Grouping runs over the complete turn stream, never a filtered one: any
@ -448,20 +486,14 @@ export function groupConsecutiveTools(
let buf: { turn: ToolTurn; turnIndex: number }[] = [];
function flush() {
if (buf.length === 0) return;
if (buf.length === 1) {
out.push({ kind: "single", turn: buf[0].turn, turnIndex: buf[0].turnIndex });
} else {
const bounds = toolGroupBounds(buf);
out.push({
kind: "group",
toolName: buf[0].turn.toolName,
ts: bounds.ts,
durationMs: bounds.durationMs,
children: buf,
});
}
const [first, second, ...rest] = buf;
buf = [];
if (!first) return;
out.push(
second
? toolGroupDisplayItem(first, second, rest)
: singleDisplayItem(first.turn, first.turnIndex),
);
}
for (const { turn, index } of turns) {
@ -474,7 +506,7 @@ export function groupConsecutiveTools(
if (groupable) {
buf.push({ turn, turnIndex: index });
} else {
out.push({ kind: "single", turn, turnIndex: index });
out.push(singleDisplayItem(turn, index));
}
}
flush();
@ -491,6 +523,14 @@ export function filterDisplayItems(
selectedKinds: readonly EventKind[],
search: string,
): DisplayItem[] {
if (
search.length === 0 &&
selectedKinds.length === EVENT_KINDS.length &&
EVENT_KINDS.every((kind) => selectedKinds.includes(kind))
) {
return items;
}
const kinds = new Set<string>(selectedKinds);
const needle = search.toLowerCase();
const matchesSearch = (turn: TurnType) =>
@ -511,6 +551,18 @@ export function visibleTurnCount(items: DisplayItem[]): number {
);
}
export function filterThreadDnaItems(
items: ThreadDnaItem[],
visibleItems: DisplayItem[],
): ThreadDnaItem[] {
const visibleIds = new Set(
visibleItems.map((item) => threadSelectionId(item.selection)),
);
return items.filter((item) =>
visibleIds.has(threadSelectionId(item.selection)),
);
}
// Convert the complete grouped display list into bars for the Thread DNA
// strip. Each bar carries the same selection identifier the event list uses,
// so clicking a bar opens the same side-panel entry as clicking its row.
@ -555,7 +607,7 @@ export function buildThreadDnaItems(
const turn = item.turn;
const tsMs = Date.parse(turn.ts);
if (Number.isNaN(tsMs)) continue;
const selection = displayItemSelection(item);
const selection = item.selection;
switch (turn.kind) {
case "system":
@ -664,7 +716,7 @@ export function buildThreadDnaItems(
label: `${humanizeToolName(item.toolName)} ×${item.children.length}`,
startMs: Math.max(0, startTsMs - anchorMs),
durationMs,
selection: displayItemSelection(item),
selection: item.selection,
});
advance(startTsMs + durationMs);
}
@ -755,6 +807,12 @@ function oneLine(text: string): string {
return `${collapsed.slice(0, SUMMARY_MAX_CHARS - 1)}…`;
}
function nonBlankAssistantContent(
turn: Extract<TurnType, { kind: "assistant" }>,
): string | null {
return turn.content.trim() ? turn.content : null;
}
const TOOL_NAME_DISPLAY: Record<string, string> = {
read_file: "Read",
write_file: "Write",
@ -789,13 +847,13 @@ export function turnSummary(turn: TurnType): string {
case "pair_system":
return oneLine(turn.content);
case "assistant": {
const line = oneLine(turn.content);
const line = oneLine(nonBlankAssistantContent(turn) ?? "");
if (line) return line;
// A model response that only requested tools has no text of its own;
// describe what it did instead of rendering a blank row.
const count = turn.toolCallCount ?? 0;
if (count > 0) {
return `Requested ${count} tool call${count === 1 ? "" : "s"}`;
return `Requested ${count} ${plural(count, "tool call", "tool calls")}`;
}
return "Model response contained no text";
}
@ -840,9 +898,11 @@ export function searchableText(turn: TurnType): string {
case "pair_user":
case "pair_system":
return turn.content;
case "assistant":
case "assistant": {
// Text-free responses are findable by the copy the thread shows.
return turn.content || turnSummary(turn);
const content = nonBlankAssistantContent(turn);
return content ?? turnSummary(turn);
}
case "tool":
return `${humanizeToolName(turn.toolName)} ${turn.toolName} ${turn.input} ${turn.result}`;
case "command":
@ -984,6 +1044,8 @@ function EventDetails({
if (Number.isNaN(ms)) return turn.ts;
return new Date(ms).toLocaleString();
})();
const assistantContent =
turn.kind === "assistant" ? nonBlankAssistantContent(turn) : null;
return (
<div className="space-y-5">
@ -1006,8 +1068,8 @@ function EventDetails({
{turn.kind === "assistant" && (
<>
<DetailField label="Content">
{turn.content ? (
<Markdown content={turn.content} />
{assistantContent ? (
<Markdown content={assistantContent} />
) : (
<span className="text-fg-muted">{turnSummary(turn)}</span>
)}
@ -1629,43 +1691,30 @@ function StageActivityBody({
</div>
) : (
visibleItems.map((item) => {
const selection = item.selection;
const selectionId = threadSelectionId(selection);
const isSelected = threadSelectionsEqual(
selection,
panelSelection,
);
if (item.kind === "single") {
return (
<EventRow
key={`turn-${item.turnIndex}`}
key={selectionId}
turn={item.turn}
runStart={runStart}
selected={
panelSelection?.kind === "single" &&
panelSelection.turnIndex === item.turnIndex
}
onSelect={() =>
onPanelSelectionChange({
kind: "single",
turnIndex: item.turnIndex,
})
}
selected={isSelected}
onSelect={() => onPanelSelectionChange(selection)}
/>
);
}
const childIndices = item.children.map((c) => c.turnIndex);
const groupKey = `group-${childIndices.join("-")}`;
const isSelected =
panelSelection?.kind === "group" &&
panelSelection.childTurnIndices.length === childIndices.length &&
panelSelection.childTurnIndices.every((v, i) => v === childIndices[i]);
return (
<ToolGroupRow
key={groupKey}
key={selectionId}
group={item}
runStart={runStart}
selected={isSelected}
onSelect={() =>
onPanelSelectionChange({
kind: "group",
childTurnIndices: childIndices,
})
}
onSelect={() => onPanelSelectionChange(selection)}
/>
);
})
@ -1776,19 +1825,21 @@ function RunStageActivityStage({
() => filterDisplayItems(displayItems, selectedKinds, search),
[displayItems, selectedKinds, search],
);
const visibleCount = useMemo(
() => visibleTurnCount(visibleItems),
[visibleItems],
);
const allDnaItems = useMemo(
() => buildThreadDnaItems(displayItems, runStart),
[displayItems, runStart],
);
const threadDnaItems = useMemo(() => {
if (visibleItems.length === displayItems.length) return allDnaItems;
const visibleKeys = new Set(
visibleItems.map((item) => threadSelectionKey(displayItemSelection(item))),
);
return allDnaItems.filter((item) =>
visibleKeys.has(threadSelectionKey(item.selection)),
);
}, [allDnaItems, displayItems, visibleItems]);
const threadDnaItems = useMemo(
() =>
visibleItems === displayItems
? allDnaItems
: filterThreadDnaItems(allDnaItems, visibleItems),
[allDnaItems, displayItems, visibleItems],
);
const openTurn =
panelSelection?.kind === "single" ? turns[panelSelection.turnIndex] ?? null : null;
@ -1796,13 +1847,13 @@ function RunStageActivityStage({
// corrupt or drop the identity of an open selection.
const openGroup = useMemo<Extract<DisplayItem, { kind: "group" }> | null>(() => {
if (panelSelection?.kind !== "group") return null;
const wanted = panelSelection.childTurnIndices;
for (const item of displayItems) {
if (item.kind !== "group") continue;
const matches =
item.children.length === wanted.length &&
item.children.every((c, i) => c.turnIndex === wanted[i]);
if (matches) return item;
if (
item.kind === "group" &&
threadSelectionsEqual(item.selection, panelSelection)
) {
return item;
}
}
return null;
}, [displayItems, panelSelection]);
@ -1884,7 +1935,7 @@ function RunStageActivityStage({
onSearchChange={onSearchChange}
filteredCount={
effectiveTab === "primary"
? visibleTurnCount(visibleItems)
? visibleCount
: filteredDebugEvents.length
}
totalCount={