diff --git a/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx b/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx index 2ca94c645..efa976917 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx @@ -79,8 +79,8 @@ const ROW_ESTIMATE = { tool: 180, "lane-open": 44, "lane-close": 34, - parallel: 46, - "parallel-turns": 46, + parallel: 34, + "parallel-turns": 34, structure: 30, note: 86, divider: 60, @@ -156,12 +156,6 @@ export function SessionTranscript({ useAnimationFrameWithResizeObserver: true, }) - const indexByKey = useMemo(() => new Map(rows.map((row, index) => [row.key, index])), [rows]) - const jumpTo = (key: string) => { - const index = indexByKey.get(key) - if (index !== undefined) virtualizer.scrollToIndex(index, { align: "start" }) - } - // A pasted `?span=` link lands on the block it names. Once, on mount — after // that the URL follows the reader rather than leading them. Not before the // scroller exists, though: the list element attaches in a layout effect, so @@ -215,7 +209,6 @@ export function SessionTranscript({ selected={"span" in row && row.span.spanId === selectedSpanId} onSelectSpan={onSelectSpan} onOpenTraceView={onOpenTraceView} - onJump={jumpTo} /> ) @@ -237,7 +230,6 @@ interface BlockProps { selected: boolean onSelectSpan: (spanId: string | undefined) => void onOpenTraceView: () => void - onJump: (key: string) => void } /** @@ -387,19 +379,6 @@ function TurnChapter({ {turn.agentName !== undefined && } {turn.failed && Failed} - {/* The cluster's banner and the indentation carry the explanation; the - pill is the glanceable trace of it that survives collapse. Bounded, - unlike the old per-turn jump chips, whose row of unshrinkable - buttons was one of the things pushing the page sideways. */} - {row.parallelWith.length > 0 && ( - turnOrdinal(ref.turn)).join(", ")}`} - > - - parallel - - )} {/* Shrinkable, unlike its neighbours: the summary joins every tool name in the turn, so it truncates rather than widening the page. */} {collapsed && {summariseTurn(row)}} @@ -595,7 +574,11 @@ function SystemBlock({
{row.text}} + body={ + raw ? undefined : ( + {row.text} + ) + } expanded={disclosed(openRows, textKey, false)} onToggleExpanded={() => onToggleRow(textKey)} /> @@ -1073,7 +1056,7 @@ function LaneOpen({ timePadding="pt-2" className="pt-2.5" > -
+
{row.laneKind === "subagent" ? "Subagent" : "Agent"} @@ -1092,17 +1075,6 @@ function LaneOpen({ : `· trace ${row.span.traceId.slice(0, 8)}`}{" "} · {row.spanCount} spans · {formatDuration(row.span.durationMs)} - {/* The fork banner above already lists every sibling lane; this is the - glanceable trace of it that survives past that marker scrolling out. */} - {row.parallelWith.length > 0 && ( - ref.agentName).join(", ")}`} - > - - parallel - - )}
{/* The handoff's own payload: the `execute_tool task` span this block @@ -1165,53 +1137,26 @@ function LaneClose({ } /** - * The fork banner both parallel markers share: a bordered, tinted block whose - * every line WRAPS. The old markers were one flex line of unshrinkable text and - * chips, which is exactly the shape that pushes a page into sideways clipping — - * a marker about concurrency must never cost the reader the right edge. + * The fork marker both parallel kinds share: one rule across the column, in the + * same shape as the page's other structural lines. WHAT forked is already on + * the rows right below it — all the marker has to say is that they did not run + * in sequence, and when they were open together. */ -function ParallelBanner({ - title, - description, - refs, - onJump, -}: { - title: string - description: string - /** The forked threads, as jump chips. */ - refs: readonly { key: string; label: string }[] - onJump: (key: string) => void -}) { +function ParallelRule({ label, range }: { label: string; range: string }) { return ( -
-
- - {title} -
-

- {description} -

-
- {refs.map((ref) => ( - - ))} -
+
+ + {label} + {range} +
) } /** Only a window every member shared is reported as an overlap. A chain of - * pairwise overlaps has none, and the sentence then reports the run's extent + * pairwise overlaps has none, and the marker then reports the run's extent * instead of inventing one. */ -function overlapSentence( +function overlapWindow( row: { startMs: number endMs: number @@ -1221,8 +1166,8 @@ function overlapSentence( timeZone: string, ): string { return row.overlapStartMs !== undefined && row.overlapEndMs !== undefined - ? `they overlap ${clockOf(row.overlapStartMs, timeZone)} → ${clockOf(row.overlapEndMs, timeZone)}` - : `their runs interleave between ${clockOf(row.startMs, timeZone)} and ${clockOf(row.endMs, timeZone)}` + ? `overlap ${clockOf(row.overlapStartMs, timeZone)} → ${clockOf(row.overlapEndMs, timeZone)}` + : `interleaved ${clockOf(row.startMs, timeZone)} → ${clockOf(row.endMs, timeZone)}` } /** @@ -1232,21 +1177,18 @@ function overlapSentence( function ParallelMarker({ row, timeZone, - onJump, }: BlockProps & { row: Extract }) { return ( - ({ key: lane.key, label: lane.agentName }))} - onJump={onJump} + ) @@ -1265,24 +1207,18 @@ function ParallelMarker({ function ParallelTurnsMarker({ row, timeZone, - onJump, }: BlockProps & { row: Extract }) { return ( - ({ - key: ref.key, - label: `${turnOrdinal(ref.turn).toUpperCase()}${ref.turn.agentName === undefined ? "" : ` ${ref.turn.agentName}`}`, - }))} - onJump={onJump} + ) diff --git a/apps/web/src/lib/agent-sessions/session-transcript.test.ts b/apps/web/src/lib/agent-sessions/session-transcript.test.ts index 52013a874..c6f9957e3 100644 --- a/apps/web/src/lib/agent-sessions/session-transcript.test.ts +++ b/apps/web/src/lib/agent-sessions/session-transcript.test.ts @@ -237,18 +237,13 @@ describe("buildTranscript — lanes and parallel markers", () => { ]) }) - it("marks overlapping lanes with reciprocal jump links", () => { + it("opens one marker over the lanes that overlapped", () => { const rows = transcript(twoLanes) const opens = rows.filter((row) => row.kind === "lane-open") expect(opens.map((row) => row.agentName)).toEqual(["db-lane", "trace-lane"]) - expect(opens[0]!.parallelWith.map((ref) => ref.agentName)).toEqual(["trace-lane"]) - expect(opens[1]!.parallelWith.map((ref) => ref.agentName)).toEqual(["db-lane"]) - // The links point at the other lane's own row key. - expect(opens[0]!.parallelWith[0]!.key).toBe(opens[1]!.key) const marker = findRow(rows, "parallel") - expect(marker.forkedBy).toBe("planner-agent") - expect(marker.lanes).toHaveLength(2) + expect(marker.lanes.map((ref) => ref.agentName)).toEqual(["db-lane", "trace-lane"]) // The fork runs forwards, and both lanes really were open together. expect(marker.startMs).toBeLessThan(marker.endMs) expect(marker.overlapStartMs).toBe(T0 + 2 * SECOND) @@ -1108,7 +1103,7 @@ describe("buildTranscript — parallel clustering", () => { }), ] - it("keeps a staggered run in one cluster and links only the pairs that overlapped", () => { + it("keeps a staggered run in one cluster", () => { const rows = transcript(chain) const marker = findRow(rows, "parallel") expect(marker.lanes.map((lane) => lane.agentName)).toEqual(["a-lane", "b-lane", "c-lane"]) @@ -1119,13 +1114,6 @@ describe("buildTranscript — parallel clustering", () => { // than reporting one that runs backwards. expect(marker.overlapStartMs).toBeUndefined() expect(marker.overlapEndMs).toBeUndefined() - - const opens = findRows(rows, "lane-open") - expect(opens.map((row) => row.parallelWith.map((ref) => ref.agentName))).toEqual([ - ["b-lane"], - ["a-lane", "c-lane"], - ["b-lane"], - ]) }) // The lane that breaks the run under a "previous member" rule: it ends before @@ -1159,15 +1147,8 @@ describe("buildTranscript — parallel clustering", () => { const rows = transcript(nested) expect(findRows(rows, "parallel")).toHaveLength(1) const marker = findRow(rows, "parallel") - expect(marker.lanes).toHaveLength(3) + expect(marker.lanes.map((lane) => lane.agentName)).toEqual(["long-lane", "x-lane", "y-lane"]) expect(marker.startMs).toBeLessThan(marker.endMs) - - const opens = findRows(rows, "lane-open") - expect(opens.map((row) => row.parallelWith.map((ref) => ref.agentName))).toEqual([ - ["x-lane", "y-lane"], - ["long-lane"], - ["long-lane"], - ]) }) }) @@ -1689,39 +1670,25 @@ describe("buildTranscript — parallel turns", () => { for (const row of findRows(rows, "assistant")) expect(row.depth).toBe(0) }) - it("gives each member turn header reciprocal jump data", () => { - const rows = transcript(twoTurns) - const headers = findRows(rows, "turn") - expect(headers.map((row) => row.turn.agentName)).toEqual(["log-lane", "metric-lane"]) - expect(headers[0]!.parallelWith.map((ref) => ref.turn.agentName)).toEqual(["metric-lane"]) - expect(headers[1]!.parallelWith.map((ref) => ref.turn.agentName)).toEqual(["log-lane"]) - // The links point at the other turn's own header row key. - expect(headers[0]!.parallelWith[0]!.key).toBe(headers[1]!.key) - expect(headers[1]!.parallelWith[0]!.key).toBe(headers[0]!.key) - }) - - // The marker's jump targets have to be row keys, not turn-shaped lookalikes: - // a key that resolves to nothing scrolls nowhere and reports no error. - it("resolves every jump key to a row that is on the page", () => { + // The marker counts its members, so a member it names has to be a turn that + // is really on the page — a phantom would be counted into the headline. + it("names every member turn with the key its header renders under", () => { const rows = transcript(twoTurns) const keys = new Set(rows.map((row) => row.key)) const marker = findRow(rows, "parallel-turns") + expect(marker.turns.map((ref) => ref.turn.agentName)).toEqual(["log-lane", "metric-lane"]) for (const ref of marker.turns) expect(keys.has(ref.key)).toBe(true) - for (const header of findRows(rows, "turn")) { - for (const ref of header.parallelWith) expect(keys.has(ref.key)).toBe(true) - } }) - it("keeps the marker and the chip data on a collapsed member", () => { + it("keeps the marker over a collapsed member", () => { const rows = transcript(twoTurns, { collapsedTurns: new Set(["conversation:conv-a"]) }) // Collapse hides a turn's body, never its header — so the chapter-level // concurrency survives it. expect(kinds(rows)).toEqual(["parallel-turns", "turn", "turn", "assistant"]) - const collapsed = findRows(rows, "turn")[0]! - expect(collapsed.parallelWith.map((ref) => ref.turn.agentName)).toEqual(["metric-lane"]) + expect(findRows(rows, "turn")[0]!.depth).toBe(1) }) - it("keeps a staggered run in one cluster and links only the pairs that overlapped", () => { + it("keeps a staggered run in one cluster", () => { const staggered = [ ...conversationTurn({ id: "a", agentName: "a-lane", startMs: 0, durationMs: 10 * SECOND }), ...conversationTurn({ @@ -1748,10 +1715,8 @@ describe("buildTranscript — parallel turns", () => { // than reporting one that runs backwards. expect(marker.overlapStartMs).toBeUndefined() expect(marker.overlapEndMs).toBeUndefined() - - expect( - findRows(rows, "turn").map((row) => row.parallelWith.map((ref) => ref.turn.agentName)), - ).toEqual([["b-lane"], ["a-lane", "c-lane"], ["b-lane"]]) + // Every member of the chain hangs off the one marker. + for (const header of findRows(rows, "turn")) expect(header.depth).toBe(1) }) // The turn that breaks the run under a "previous member" rule: it ends before @@ -1781,10 +1746,7 @@ describe("buildTranscript — parallel turns", () => { // The two short turns never met each other, so only the long one is shared. expect(marker.overlapStartMs).toBeUndefined() expect(marker.overlapEndMs).toBeUndefined() - - expect( - findRows(rows, "turn").map((row) => row.parallelWith.map((ref) => ref.turn.agentName)), - ).toEqual([["x-lane", "y-lane"], ["long-lane"], ["long-lane"]]) + for (const header of findRows(rows, "turn")) expect(header.depth).toBe(1) }) it("says nothing about turns that ran one after the other", () => { @@ -1799,7 +1761,7 @@ describe("buildTranscript — parallel turns", () => { ] const rows = transcript(sequential) expect(kinds(rows)).not.toContain("parallel-turns") - for (const header of findRows(rows, "turn")) expect(header.parallelWith).toStrictEqual([]) + for (const header of findRows(rows, "turn")) expect(header.depth).toBe(0) }) // An `empty-turn` stub is HTTP/DB work with no agent activity in it. Pairing @@ -1819,15 +1781,15 @@ describe("buildTranscript — parallel turns", () => { ] const rows = transcript(spans) expect(kinds(rows)).toEqual(["turn", "assistant", "empty-turn"]) - expect(findRow(rows, "turn").parallelWith).toStrictEqual([]) + expect(findRow(rows, "turn").depth).toBe(0) }) // Structural chrome, like the lane markers: the filtered view no longer has // the ordering the marker describes. - it("drops the marker and the chips while a query is active", () => { + it("drops the marker and its indentation while a query is active", () => { const rows = transcript(twoTurns, { query: "metric-lane" }) expect(kinds(rows)).toEqual(["turn", "assistant"]) - expect(findRow(rows, "turn").parallelWith).toStrictEqual([]) + expect(findRow(rows, "turn").depth).toBe(0) }) it("marks the lab fixture's dispatched fan-out", () => { diff --git a/apps/web/src/lib/agent-sessions/session-transcript.ts b/apps/web/src/lib/agent-sessions/session-transcript.ts index 9b3a179ea..cb786995d 100644 --- a/apps/web/src/lib/agent-sessions/session-transcript.ts +++ b/apps/web/src/lib/agent-sessions/session-transcript.ts @@ -114,8 +114,6 @@ export type TranscriptRow = /** AI spans the turn actually renders — deduped, app spans excluded. */ readonly aiSpanCount: number readonly toolNames: readonly string[] - /** Sibling turns this one genuinely overlapped — see `markParallelTurns`. */ - readonly parallelWith: readonly TranscriptTurnRef[] }) /** A turn the transcript has nothing to say about, holding its ordinal open. */ | (RowBase & { readonly kind: "empty-turn"; readonly turn: SessionTurn }) @@ -168,7 +166,6 @@ export type TranscriptRow = readonly spanCount: number /** What the delegating tool call asked for, where a lane was delegated. */ readonly args: TranscriptPayload | undefined - readonly parallelWith: readonly TranscriptLaneRef[] }) | (RowBase & { readonly kind: "lane-close" @@ -183,7 +180,6 @@ export type TranscriptRow = }) | (RowBase & { readonly kind: "parallel" - readonly forkedBy: string | undefined /** The fork: from the first lane opening to the last one closing. */ readonly startMs: number readonly endMs: number @@ -272,7 +268,7 @@ export function buildTranscript(input: TranscriptInput): readonly TranscriptRow[ const turnRows = input.turns.map((turn) => buildTurn(turn, input, categoryOf)) // Structural chrome, like the lane markers: a filtered view no longer has the // ordering the marker describes, so it is not built at all. - const concurrent = filtering ? new Map() : markParallelTurns(turnRows) + const concurrent = filtering ? new Map() : markParallelTurns(turnRows) // Capture coverage is a fact about the session, so it is counted over every // turn before the first row is emitted. @@ -299,15 +295,14 @@ export function buildTranscript(input: TranscriptInput): readonly TranscriptRow[ // a header over nothing is worse than no header. Collapse does not change // that: the filter judges both states by the same rows. if (filtering && entry.rows.length === 0) continue - const marking = concurrent.get(entry.turn.id) // The marker opens the cluster, so it is carried by its first member. - if (marking?.marker !== undefined) body.push(marking.marker) + const marker = concurrent.get(entry.turn.id) + if (marker !== undefined) body.push(marker) // A cluster member's whole chapter shifts one lane right, the same move a // lane makes inside a turn: the indentation is what says "these chapters // hang off the fork above" without the reader having to parse the marker. - const indent = marking === undefined ? 0 : 1 - const header = marking?.header ?? entry.header - body.push(indent === 0 ? header : { ...header, depth: header.depth + indent }) + const indent = concurrent.has(entry.turn.id) ? 1 : 0 + body.push(indent === 0 ? entry.header : { ...entry.header, depth: entry.header.depth + indent }) // Per-turn only where the session-level banner is not already up. if ( !bannerUp && @@ -400,9 +395,6 @@ function buildTurn( toolNames: distinct( toolSpans.map((span) => span.genAi.toolName).filter((name): name is string => name !== undefined), ), - // Filled in by `markParallelTurns`, which needs every turn to be built - // before it can tell which of them overlapped. - parallelWith: [], } // A collapsed turn renders its header and nothing else, so none of the work @@ -731,7 +723,6 @@ function openedLane( parentAgentName: scope.agentName, spanCount: walk.counts.spans, args: payload(delegating === undefined ? undefined : toolArgsText(delegating)), - parallelWith: [], }, ...walk.rows, { @@ -789,21 +780,8 @@ function markParallelLanes( const out = [...rows] const markers: { at: number; row: TranscriptRow }[] = [] for (const cluster of clusters) { - for (const lane of cluster.members) { - // `at` is where the lane's rows were pushed and its opening row is the - // first of them — a search by key would find the same row more slowly. - const row = out[lane.at] - if (row?.kind !== "lane-open") continue - // Only the lanes this one GENUINELY overlapped: a cluster can be a chain - // (A with B, B with C, A never with C), and linking A to C would be the - // same invention the marker exists to prevent. - out[lane.at] = { - ...row, - parallelWith: cluster.members - .filter((other) => overlaps(lane, other)) - .map((other) => other.ref), - } - } + // `at` is where the lane's rows were pushed and its opening row is the + // first of them — the marker opens the cluster right above it. const first = cluster.members[0]! markers.push({ at: first.at, @@ -811,7 +789,6 @@ function markParallelLanes( kind: "parallel", key: `${first.ref.key}:parallel`, depth: scope.depth, - forkedBy: scope.agentName, startMs: cluster.startMs, endMs: cluster.endMs, overlapStartMs: cluster.overlapStartMs, @@ -896,20 +873,6 @@ function clusterByOverlap(items: readonly T[]): readonly Ove }) } -/** Two intervals open at the same moment. Identity, not keys: the members are - * the very objects handed to `clusterByOverlap`. */ -function overlaps(a: T, b: T): boolean { - return a !== b && a.startMs < b.endMs && b.startMs < a.endMs -} - -/** What a cluster does to one of its member turns. */ -interface TurnMarking { - /** The header again, carrying the turns this one overlapped. */ - readonly header: Extract - /** The cluster's marker, on its first member only. */ - readonly marker: TranscriptRow | undefined -} - /** * Chapter-level parallelism: turns that ran at the same time. * @@ -924,7 +887,7 @@ interface TurnMarking { * a placeholder for HTTP/DB work with no agent activity in it, and pairing one * with a real turn would announce a concurrency the reader cannot see. */ -function markParallelTurns(entries: readonly TurnRows[]): ReadonlyMap { +function markParallelTurns(entries: readonly TurnRows[]): ReadonlyMap { const items = entries .filter((entry) => entry.aiSpanCount > 0) .map((entry, order) => ({ @@ -938,34 +901,20 @@ function markParallelTurns(entries: readonly TurnRows[]): ReadonlyMap() + // Membership is the key; only the cluster's first member carries its marker. + const marked = new Map() for (const cluster of clusterByOverlap(items)) { - for (const item of cluster.members) { - marked.set(item.entry.turn.id, { - header: { - ...item.entry.header, - // Only the turns this one GENUINELY overlapped: a cluster can be a - // chain (A with B, B with C, A never with C). - parallelWith: cluster.members - .filter((other) => overlaps(item, other)) - .map((other) => other.ref), - }, - marker: undefined, - }) - } + for (const item of cluster.members) marked.set(item.entry.turn.id, undefined) const first = cluster.members.reduce((a, b) => (a.order <= b.order ? a : b)) marked.set(first.entry.turn.id, { - ...marked.get(first.entry.turn.id)!, - marker: { - kind: "parallel-turns", - key: `${first.ref.key}:parallel-turns`, - depth: 0, - startMs: cluster.startMs, - endMs: cluster.endMs, - overlapStartMs: cluster.overlapStartMs, - overlapEndMs: cluster.overlapEndMs, - turns: cluster.members.map((item) => item.ref), - }, + kind: "parallel-turns", + key: `${first.ref.key}:parallel-turns`, + depth: 0, + startMs: cluster.startMs, + endMs: cluster.endMs, + overlapStartMs: cluster.overlapStartMs, + overlapEndMs: cluster.overlapEndMs, + turns: cluster.members.map((item) => item.ref), }) } return marked