mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
refactor(web): simplify thread DNA selection identity
This commit is contained in:
parent
110058bb4b
commit
7b82a150ad
3 changed files with 237 additions and 169 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue