fix(lens): paginate visible conversation entries (#44878)

* fix(lens): paginate visible conversation entries

* fix(lens): stop pagination at exhausted subagents

* refactor(lens): derive pending branches without mutation

* fix(lens): bound pending ancestry work for deep traces
This commit is contained in:
moe-berri 2026-10-06 12:10:19 -07:00 • committed by GitHub
parent 5147aefa8b
commit fb554d34f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 578 additions and 73 deletions

View file

@ -120,7 +120,7 @@ describe("TraceConversation", () => {
expect(within(result).getByText(/"exit_code": 1/, { selector: "pre" })).toHaveTextContent('"error": null');
});
it("loads only twenty full steps at a time and fetches the remainder on demand", async () => {
it("loads twenty span details initially and pages conversation entries on demand", async () => {
const user = userEvent.setup();
const spans = [
root,
@ -128,7 +128,7 @@ describe("TraceConversation", () => {
];
const long = { ...trace, spans } as Trace;
renderWithProviders(<TraceConversation trace={long} accessToken="test" onOpenStep={vi.fn()} />);
const more = await screen.findByRole("button", { name: "Load next 11 steps" });
const more = await screen.findByRole("button", { name: "Load next 20 entries" });
await waitFor(() => expect(more).toBeEnabled());
expect(agentTraceSpanCall).toHaveBeenCalledTimes(20);
expect(screen.queryByText("The release is ready")).not.toBeInTheDocument();
@ -150,6 +150,200 @@ describe("TraceConversation", () => {
expect(screen.queryByText("End of conversation")).not.toBeInTheDocument();
});
it("adds twenty visible entries across hidden subagents, supplemental spans, and server pages", async () => {
const user = userEvent.setup();
const child = { ...root, span_id: "child", parent_span_id: "root", name: "Reviewer", start_offset_ms: 1 };
const childSpans = Array.from({ length: 50 }, (_, index) => [
{
...tool,
span_id: `child-${index}`,
parent_span_id: "child",
name: `child tool ${index}`,
start_offset_ms: 2 + index * 2,
},
{
...tool,
span_id: `supplement-${index}`,
parent_span_id: "child",
name: "claude_code.tool_result",
framework: "claude-code",
type: "event",
start_offset_ms: 3 + index * 2,
},
]).flat();
const parentSpans = Array.from({ length: 45 }, (_, index) => [
{ ...tool, span_id: `parent-${index}`, name: `parent tool ${index}`, start_offset_ms: 102 + index * 2 },
{ ...tool, span_id: `empty-${index}`, type: "llm", start_offset_ms: 103 + index * 2 },
]).flat();
const spans = [root, child, ...childSpans, ...parentSpans] as Trace["spans"];
const first: Trace = { ...trace, spans: spans.slice(0, 120), next_cursor: "next-page" };
const last: Trace = { ...trace, spans: spans.slice(120), next_cursor: null };
vi.mocked(agentTraceCall).mockImplementation(async (_token, _trace, _ref, cursor) => (cursor ? last : first));
vi.mocked(agentTraceSpanCall).mockImplementation(async (_token, _trace, id) => {
if (id === "root" || id === "child")
return { ...rootDetail, span_id: id, input: id === "root" ? "Main task" : "Child task", output: "" };
if (id.startsWith("empty-") || id.startsWith("supplement-"))
return { span_id: id, input: "", output: "", attributes: {} };
return { ...toolDetail, span_id: id };
});
renderWithProviders(
<RoutedRunView traceId={trace.summary.trace_id} accessToken="test" onBack={vi.fn()} embedded />,
);
await user.click(await screen.findByRole("tab", { name: "Conversation" }));
expect(await screen.findByText("2 entries shown")).toBeVisible();
expect(agentTraceSpanCall).toHaveBeenCalledTimes(20);
await user.click(screen.getByRole("button", { name: "Load next 20 entries", exact: true }));
expect(await screen.findByText("22 entries shown")).toBeVisible();
expect(screen.getAllByRole("region", { name: /Conversation step parent tool/ })).toHaveLength(20);
expect(
screen.queryByRole("button", { name: "Expand parent tool 20 tool call", exact: true }),
).not.toBeInTheDocument();
expect(screen.getByRole("region", { name: "Conversation step child tool 0", exact: true })).not.toBeVisible();
expect(agentTraceCall).toHaveBeenCalledWith("test", trace.summary.trace_id, undefined, "next-page");
await user.click(screen.getByText("Subagent: Reviewer", { exact: true }));
expect(screen.getAllByRole("region", { name: /Conversation step child tool/ })).toHaveLength(19);
await user.click(screen.getByRole("button", { name: "Load next 20 entries in Reviewer", exact: true }));
expect(screen.getAllByRole("region", { name: /Conversation step child tool/ })).toHaveLength(39);
expect(screen.getAllByRole("region", { name: /Conversation step parent tool/ })).toHaveLength(20);
expect(screen.queryByText("End of conversation")).not.toBeInTheDocument();
});
it.each([
{ duration: 10, nextCursor: null },
{ duration: 1000, nextCursor: null },
{ duration: 10, nextCursor: "unrelated-page" },
])("hides an exhausted subagent control with unrelated work remaining (%j)", async ({ duration, nextCursor }) => {
const user = userEvent.setup();
const child = {
...root,
span_id: "child",
parent_span_id: "root",
name: "Reviewer",
start_offset_ms: 1,
duration_ms: duration,
};
const spans = [
root,
child,
{ ...tool, span_id: "child-tool", parent_span_id: "child", start_offset_ms: 2, duration_ms: 1 },
...Array.from({ length: 40 }, (_, index) => ({
...tool,
span_id: `parent-${index}`,
start_offset_ms: 100 + index,
duration_ms: 1,
})),
] as Trace["spans"];
vi.mocked(agentTraceSpanCall).mockImplementation(async (_token, _trace, id) => {
if (id === "child") return { ...rootDetail, span_id: id, input: "Review task", output: "Review finished" };
return id === "root" ? rootDetail : { ...toolDetail, span_id: id };
});
const loadMore = vi.fn();
renderWithProviders(
<TraceConversation
trace={{ ...trace, spans, next_cursor: nextCursor }}
accessToken="test"
onOpenStep={vi.fn()}
paging={{ loading: false, failed: false, loadMore }}
/>,
);
const more = await screen.findByRole("button", { name: "Load next 20 entries", exact: true });
await waitFor(() => expect(more).toBeEnabled());
await user.click(screen.getByText("Subagent: Reviewer", { exact: true }));
expect(screen.getByText("Review finished")).toBeVisible();
expect(screen.queryByRole("button", { name: /entries in Reviewer/ })).not.toBeInTheDocument();
expect(agentTraceSpanCall).toHaveBeenCalledTimes(20);
expect(loadMore).not.toHaveBeenCalled();
});
it.each([false, true])("stops loading at the selected subagent's end (server paging: %s)", async (paged) => {
const user = userEvent.setup();
const child = {
...root,
span_id: "child",
parent_span_id: "root",
name: "Reviewer",
start_offset_ms: 1,
duration_ms: 60,
};
const spans = [
root,
child,
...Array.from({ length: 25 }, (_, index) => ({
...tool,
span_id: `child-${index}`,
parent_span_id: "child",
name: `child tool ${index}`,
start_offset_ms: 2 + index,
duration_ms: 1,
})),
...Array.from({ length: 40 }, (_, index) => ({
...tool,
span_id: `parent-${index}`,
name: `parent tool ${index}`,
start_offset_ms: 100 + index,
duration_ms: 1,
})),
] as Trace["spans"];
const first: Trace = {
...trace,
spans: paged ? spans.slice(0, 20) : spans,
next_cursor: paged ? "child-page" : null,
};
const next: Trace = { ...trace, spans: spans.slice(20, 40), next_cursor: "unrelated-page" };
vi.mocked(agentTraceCall).mockImplementation(async (_token, _trace, _ref, cursor) => (cursor ? next : first));
vi.mocked(agentTraceSpanCall).mockImplementation(async (_token, _trace, id) => {
if (id === "child") return { ...rootDetail, span_id: id, input: "Review task", output: "Review finished" };
return id === "root" ? rootDetail : { ...toolDetail, span_id: id };
});
renderWithProviders(
<RoutedRunView traceId={trace.summary.trace_id} accessToken="test" onBack={vi.fn()} embedded />,
);
await user.click(await screen.findByRole("tab", { name: "Conversation" }));
await user.click(await screen.findByText("Subagent: Reviewer", { exact: true }));
const more = await screen.findByRole("button", { name: "Load next 20 entries in Reviewer" });
await waitFor(() => expect(more).toBeEnabled());
await user.click(more);
expect(await screen.findByRole("button", { name: "Expand child tool 24 tool call" })).toBeVisible();
await waitFor(() => expect(screen.queryByRole("button", { name: /entries in Reviewer/ })).not.toBeInTheDocument());
expect(screen.getByText("Review finished")).toBeVisible();
expect(screen.getAllByRole("region", { name: /Conversation step child tool/ })).toHaveLength(25);
expect(screen.getByRole("button", { name: "Load next 20 entries", exact: true })).toBeEnabled();
expect(screen.queryByText("Loading conversation…")).not.toBeInTheDocument();
expect(agentTraceSpanCall).toHaveBeenCalledTimes(40);
expect(agentTraceCall).toHaveBeenCalledTimes(paged ? 2 : 1);
expect(agentTraceCall).not.toHaveBeenCalledWith("test", trace.summary.trace_id, undefined, "unrelated-page");
});
it("stops at a failed server page and resumes the requested entries after retry", async () => {
const user = userEvent.setup();
const first: Trace = { ...trace, next_cursor: "next-page" };
const last: Trace = {
...trace,
spans: [{ ...tool, span_id: "later", name: "later tool", start_offset_ms: 2 }],
next_cursor: null,
};
vi.mocked(agentTraceCall)
.mockResolvedValueOnce(first)
.mockRejectedValueOnce(new Error("temporarily unavailable"))
.mockResolvedValue(last);
renderWithProviders(
<RoutedRunView traceId={trace.summary.trace_id} accessToken="test" onBack={vi.fn()} embedded />,
);
await user.click(await screen.findByRole("tab", { name: "Conversation" }));
const more = await screen.findByRole("button", { name: "Load next 20 entries", exact: true });
await waitFor(() => expect(more).toBeEnabled());
await user.click(more);
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load more conversation entries");
expect(more).toBeDisabled();
expect(agentTraceCall).toHaveBeenCalledTimes(2);
expect(screen.getByText("Read the release notes")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Retry", exact: true }));
expect(await screen.findByRole("button", { name: "Expand later tool tool call" })).toBeVisible();
expect(await screen.findByText("End of conversation")).toBeVisible();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it.each([false, true])(
"checks for missing replies after the final trace page and details load (reply: %s)",
async (hasReply) => {
@ -240,7 +434,7 @@ describe("TraceConversation", () => {
);
expect(screen.getAllByText("Agent exceeded its execution limit")).toHaveLength(1);
await act(async () => rootFetch.resolve({ ...rootDetail, output }));
const more = screen.getByRole("button", { name: "Load next 5 steps" });
const more = screen.getByRole("button", { name: "Load next 20 entries" });
await waitFor(() => expect(more).toBeEnabled());
expect(screen.getAllByText("Agent exceeded its execution limit")).toHaveLength(1);
expect(screen.queryByText("The release is ready")).not.toBeInTheDocument();
@ -262,6 +456,35 @@ describe("TraceConversation", () => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("pauses a requested page at a failed prefetched detail until it is retried", async () => {
const user = userEvent.setup();
const spans = [
root,
...Array.from({ length: 45 }, (_, index) => ({
...tool,
span_id: `tool-${index}`,
name: `check ${index}`,
start_offset_ms: index + 1,
})),
] as Trace["spans"];
const retry = vi.fn().mockRejectedValueOnce(new Error("unavailable")).mockResolvedValue(toolDetail);
vi.mocked(agentTraceSpanCall).mockImplementation(async (_token, _trace, id) => {
if (id === "tool-21") return retry();
return id === "root" ? rootDetail : { ...toolDetail, span_id: id };
});
renderWithProviders(<TraceConversation trace={{ ...trace, spans }} accessToken="test" onOpenStep={vi.fn()} />);
const more = screen.getByRole("button", { name: "Load next 20 entries", exact: true });
await waitFor(() => expect(more).toBeEnabled());
await user.click(more);
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load check 21");
expect(retry).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("button", { name: "Expand check 22 tool call" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Retry step" }));
expect(await screen.findByText("40 entries shown")).toBeVisible();
expect(screen.getAllByRole("region", { name: /Conversation step check/ })).toHaveLength(39);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("holds later turns and the final answer behind a failed step until retry succeeds", async () => {
const user = userEvent.setup();
const first = { ...tool, span_id: "first", name: "First response", type: "llm", start_offset_ms: 1 };
@ -293,7 +516,7 @@ describe("TraceConversation", () => {
expect(await screen.findByText("Checking the release")).toBeVisible();
expect(screen.queryByText("The release is ready")).not.toBeInTheDocument();
expect(screen.queryByText("End of conversation")).not.toBeInTheDocument();
expect(screen.getByText("2 of 4 steps loaded")).toBeVisible();
expect(screen.getByText("2 entries shown")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Retry step" }));
expect(await screen.findByText("The release is ready")).toBeVisible();

View file

@ -1,18 +1,17 @@
"use client";
import { useQueries } from "@tanstack/react-query";
import { useState } from "react";
import { useEffect, useState } from "react";
import { ChevronRight, Wrench } from "lucide-react";
import { cn } from "@/lib/cva.config";
import CopyButton from "@/components/shared/CopyButton";
import { ToolArguments, ToolOutput } from "../content/ToolContent";
import { toolSummary } from "../content/payload";
import { useTracesApi } from "../../api";
import { Button } from "@/components/ui/button";
import {
buildConversation,
conversationSteps,
conversationWarnings,
conversationBranchGroups,
pendingConversationBranches,
groupConversation,
CONVERSATION_PAGE_SIZE,
type ConversationItem,
@ -21,44 +20,70 @@ import {
import { ErrorBlock } from "../content/SpanError";
import { Markdown } from "../content/Markdown";
import { ToolCallBlock, ToolResultCard } from "../content/Messages";
import type { SpanDetail, Trace, TraceMessage } from "../../types";
import type { Trace, TraceMessage } from "../../types";
import { fmtMs } from "../../utils";
import { useConversationDetails } from "./useConversationDetails";
export interface ConversationTracePaging {
loading: boolean;
failed: boolean;
loadMore: () => void;
}
function hasRemainingSource(branchId: string | null, sourceRemaining: boolean, pendingBranches: ReadonlySet<string>) {
return sourceRemaining && (branchId === null || pendingBranches.has(branchId));
}
export function TraceConversation({
trace,
accessToken,
onOpenStep,
paging,
}: {
trace: Trace;
accessToken: string;
onOpenStep: (id: string) => void;
paging?: ConversationTracePaging;
}) {
const traces = useTracesApi(accessToken);
const [limit, setLimit] = useState(CONVERSATION_PAGE_SIZE);
const steps = conversationSteps(trace.spans);
const visible = steps.slice(0, limit);
const { trace_id: traceId, trace_ref: traceRef } = trace.summary;
const queries = useQueries({
queries: visible.map((span) => ({
queryKey: ["agentTraceSpan", traceId, traceRef, span.span_id, accessToken],
queryFn: (): Promise<SpanDetail> => traces.span(traceId, span.span_id, traceRef),
staleTime: Infinity,
retry: false,
})),
});
const loading = queries.some((query) => query.isPending);
const failed = queries.some((query) => query.isError);
const unresolvedIndex = queries.findIndex((query) => !query.isSuccess);
const loadedCount = unresolvedIndex < 0 ? queries.length : unresolvedIndex;
const details = new Map(
queries.slice(0, loadedCount).map((query, index) => [visible[index].span_id, query.data!] as const),
);
const complete = loadedCount === steps.length;
const [pages, setPages] = useState<ReadonlyMap<string | null, number>>(new Map());
const [requestedBranch, setRequestedBranch] = useState<string | null>();
const { details, entries, complete, loading, failed, hasMore, loadMore } = useConversationDetails(trace, accessToken);
const traceComplete = complete && !trace.next_cursor;
const items = buildConversation(trace.spans, details, complete);
const pendingBranches = pendingConversationBranches(trace.spans, details, Boolean(trace.next_cursor));
const items = buildConversation(trace.spans, details, complete, pendingBranches);
const groups = groupConversation(items, trace.spans);
const requestedId = requestedBranch ?? null;
const requestedGroups = conversationBranchGroups(groups, requestedId);
const requestedLimit = pages.get(requestedId) ?? CONVERSATION_PAGE_SIZE;
const sourceRemaining = hasMore || Boolean(trace.next_cursor && paging);
const requestedSourceRemaining = hasRemainingSource(requestedId, sourceRemaining, pendingBranches);
const needsMore =
requestedBranch !== undefined && requestedGroups.length < requestedLimit && requestedSourceRemaining;
const busy = loading || Boolean(paging?.loading);
const blocked = failed || Boolean(paging?.failed);
const loadTracePage = paging?.loadMore;
useEffect(() => {
if (!needsMore || busy || blocked) return;
if (hasMore) {
const controller = new AbortController();
void loadMore(controller.signal);
return () => controller.abort();
}
if (trace.next_cursor) loadTracePage?.();
}, [needsMore, busy, blocked, hasMore, loadMore, trace.next_cursor, loadTracePage]);
const fillingPage = needsMore && sourceRemaining && !blocked;
const loadEntries = (branchId: string | null, shown: number) => {
setPages((current) => new Map([...current, [branchId, shown + CONVERSATION_PAGE_SIZE]]));
setRequestedBranch(branchId);
};
const warnings = conversationWarnings(details, traceComplete);
const multipleAgents = new Set(items.map((item) => item.agentId).filter(Boolean)).size > 1;
const inlineErrorIds = new Set(items.filter((item) => item.showError).map((item) => item.span.span_id));
const rootLimit = pages.get(null) ?? CONVERSATION_PAGE_SIZE;
const inlineErrorIds = new Set(
groups
.slice(0, rootLimit)
.flatMap((group) => (group.kind === "item" && group.item.showError ? [group.item.span.span_id] : [])),
);
const rootErrors = trace.spans.filter((span) => {
const failedRoot = span.parent_span_id === null && span.status === "error" && span.type !== "tool";
return failedRoot && !inlineErrorIds.has(span.span_id);
@ -75,59 +100,104 @@ export function TraceConversation({
</p>
))}
<ConversationGroups
groups={groupConversation(items, trace.spans)}
groups={groups}
multipleAgents={multipleAgents}
onOpenStep={onOpenStep}
pages={pages}
sourceRemaining={sourceRemaining}
pendingBranches={pendingBranches}
disabled={busy || blocked || fillingPage}
onLoadEntries={loadEntries}
complete={traceComplete}
/>
<ConversationFeedback
entries={entries}
loading={busy || fillingPage}
complete={traceComplete}
empty={items.length === 0}
paging={paging}
/>
{queries.map(
(query, index) =>
query.isError && (
<div key={visible[index].span_id} role="alert" className="rounded-md border p-3 text-sm">
Could not load {visible[index].name}. Retry this step to continue the conversation.
<Button variant="outline" size="sm" className="mt-2" onClick={() => query.refetch()}>
Retry step
</Button>
</div>
),
)}
{loading && (
<p role="status" className="py-4 text-sm text-muted-foreground">
Loading conversation…
</p>
)}
{traceComplete && items.length === 0 && (
<p className="text-sm text-muted-foreground">No conversation content recorded.</p>
)}
<div className="flex items-center justify-between gap-3 border-t pt-4 text-xs text-muted-foreground">
<span>{traceComplete ? "End of conversation" : `${loadedCount} of ${steps.length} steps loaded`}</span>
{visible.length < steps.length && (
<Button
variant="outline"
size="sm"
disabled={loading || failed}
onClick={() => setLimit((current) => current + CONVERSATION_PAGE_SIZE)}
>
Load next {Math.min(CONVERSATION_PAGE_SIZE, steps.length - visible.length)} steps
</Button>
)}
</div>
</div>
</section>
);
}
function ConversationFeedback({
entries,
loading,
complete,
empty,
paging,
}: {
entries: ReturnType<typeof useConversationDetails>["entries"];
loading: boolean;
complete: boolean;
empty: boolean;
paging?: ConversationTracePaging;
}) {
return (
<>
{entries.map(
({ query, span }) =>
query.isError && (
<div key={span.span_id} role="alert" className="rounded-md border p-3 text-sm">
Could not load {span.name}. Retry this step to continue the conversation.
<Button variant="outline" size="sm" className="mt-2" onClick={() => query.refetch()}>
Retry step
</Button>
</div>
),
)}
{loading && (
<p role="status" className="py-4 text-sm text-muted-foreground">
Loading conversation…
</p>
)}
{complete && empty && <p className="text-sm text-muted-foreground">No conversation content recorded.</p>}
{paging?.failed && (
<p role="alert" className="text-sm text-destructive">
Could not load more conversation entries. Use Retry or Refresh trace above to continue.
</p>
)}
</>
);
}
function ConversationGroups({
groups,
multipleAgents,
onOpenStep,
branchId = null,
name,
pages,
sourceRemaining,
pendingBranches,
disabled,
onLoadEntries,
complete,
}: {
groups: ConversationGroup[];
multipleAgents: boolean;
onOpenStep: (id: string) => void;
branchId?: string | null;
name?: string;
pages: ReadonlyMap<string | null, number>;
sourceRemaining: boolean;
pendingBranches: ReadonlySet<string>;
disabled: boolean;
onLoadEntries: (branchId: string | null, shown: number) => void;
complete: boolean;
}) {
const limit = pages.get(branchId) ?? CONVERSATION_PAGE_SIZE;
const visible = groups.slice(0, limit);
const branchRemaining = hasRemainingSource(branchId, sourceRemaining, pendingBranches);
const hasMore = visible.length < groups.length || branchRemaining;
const nextCount = branchRemaining
? CONVERSATION_PAGE_SIZE
: Math.min(CONVERSATION_PAGE_SIZE, groups.length - visible.length);
return (
<>
{groups.map((group) =>
{visible.map((group) =>
group.kind === "item" ? (
<ConversationStep
key={group.item.id}
@ -137,13 +207,46 @@ function ConversationGroups({
/>
) : (
<details key={group.id} className="min-w-0 rounded-md border p-3">
<summary className="cursor-pointer text-sm font-medium">Subagent: {group.name}</summary>
<summary className="cursor-pointer text-sm font-medium">
Subagent: {group.name}
<span className="ml-2 text-xs font-normal text-muted-foreground">
{group.children.length} entries loaded
</span>
</summary>
<div className="mt-4 min-w-0 space-y-5 border-l pl-3">
<ConversationGroups groups={group.children} multipleAgents={multipleAgents} onOpenStep={onOpenStep} />
<ConversationGroups
groups={group.children}
multipleAgents={multipleAgents}
onOpenStep={onOpenStep}
branchId={group.id}
name={group.name}
pages={pages}
sourceRemaining={sourceRemaining}
pendingBranches={pendingBranches}
disabled={disabled}
onLoadEntries={onLoadEntries}
complete={complete}
/>
</div>
</details>
),
)}
<div className="flex items-center justify-between gap-3 border-t pt-4 text-xs text-muted-foreground">
<span>
{complete && !hasMore && branchId === null ? "End of conversation" : `${visible.length} entries shown`}
</span>
{hasMore && (
<Button
variant="outline"
size="sm"
disabled={disabled}
aria-label={name ? `Load next ${nextCount} entries in ${name}` : undefined}
onClick={() => onLoadEntries(branchId, visible.length)}
>
Load next {nextCount} entries
</Button>
)}
</div>
</>
);
}

View file

@ -5,6 +5,7 @@ import {
newConversationMessages,
groupConversation,
conversationWarnings,
pendingConversationBranches,
} from "./conversation";
import type { Span, SpanDetail, TraceMessage } from "../../types";
import research from "../../__fixtures__/research_trace.json";
@ -26,6 +27,74 @@ const detail = (span_id: string, input: unknown, output: unknown): SpanDetail =>
});
describe("trace conversation", () => {
it("keeps missing nested details pending through framework and native agent ancestors", () => {
const agent = {
...root,
span_id: "agent",
parent_span_id: "root",
type: "tool",
name: "Agent",
framework: "claude-code",
};
const framework = { ...root, span_id: "framework", parent_span_id: "agent", type: "framework" };
const child = { ...root, span_id: "child", parent_span_id: "framework" };
const other = { ...root, span_id: "other", parent_span_id: "root" };
const spans = [root, agent, framework, child, other] as Span[];
const details = new Map(
spans.filter((span) => span !== child).map((span) => [span.span_id, detail(span.span_id, [], [])]),
);
expect([...pendingConversationBranches(spans, details, false)].toSorted()).toEqual([
"agent",
"child",
"framework",
"root",
]);
const completeDetails = new Map([...details, [child.span_id, detail(child.span_id, [], [])]]);
expect(pendingConversationBranches(spans, completeDetails, false).size).toBe(0);
});
it.each(["child", "missing-parent"])("stops pending ancestry at cycles or missing parents (%s)", (parentId) => {
const agent = { ...root, span_id: "agent", parent_span_id: parentId };
const child = { ...root, span_id: "child", parent_span_id: "agent" };
const spans = [root, agent, child];
const details = new Map([
[root.span_id, detail(root.span_id, [], [])],
[agent.span_id, detail(agent.span_id, [], [])],
]);
expect([...pendingConversationBranches(spans, details, false)].toSorted()).toEqual(["agent", "child"]);
});
it.each([false, true])("handles 20,000-level pending ancestry without overflowing (cycle: %s)", (cycle) => {
const rootParent = cycle ? "deep-19999" : null;
const chain = Array.from({ length: 20_000 }, (_, index) => ({
...root,
span_id: `deep-${index}`,
parent_span_id: index === 0 ? rootParent : `deep-${index - 1}`,
}));
const spans = [...chain, root];
const details = new Map(
spans.filter((span) => span.span_id !== "deep-19999").map((span) => [span.span_id, detail(span.span_id, [], [])]),
);
const pending = pendingConversationBranches(spans, details, false);
expect(pending.size).toBe(chain.length);
expect(chain.every((span) => pending.has(span.span_id))).toBe(true);
expect(pending.has(root.span_id)).toBe(false);
});
it.each([
{ boundary: 10, morePages: true, pending: true },
{ boundary: 20, morePages: true, pending: true },
{ boundary: 21, morePages: true, pending: false },
{ boundary: 10, morePages: false, pending: false },
])("only waits for pages that could contain later branch children (%j)", ({ boundary, morePages, pending }) => {
const agent = { ...root, span_id: "agent", parent_span_id: "root", start_offset_ms: 1, duration_ms: 5 };
const child = { ...agent, span_id: "child", parent_span_id: "agent", start_offset_ms: 2, duration_ms: 18 };
const other = { ...root, span_id: "other", parent_span_id: "root", start_offset_ms: boundary };
const spans = [root, agent, child, other];
const details = new Map(spans.map((span) => [span.span_id, detail(span.span_id, [], [])]));
expect(pendingConversationBranches(spans, details, morePages).has("agent")).toBe(pending);
});
it.each(["reviewer", "__proto__", "constructor"])("keeps repeated agent name %s stable as steps load", (name) => {
const first = { ...root, span_id: "first", name, start_offset_ms: 1 };
const second = { ...first, span_id: "second", start_offset_ms: 2 };

View file

@ -17,6 +17,48 @@ export function conversationSteps(spans: readonly Span[]): Span[] {
.sort((a, b) => a.start_offset_ms - b.start_offset_ms);
}
export function pendingConversationBranches(
spans: readonly Span[],
details: ReadonlyMap<string, SpanDetail>,
hasMoreSpans: boolean,
): ReadonlySet<string> {
const byId = new Map(spans.map((span) => [span.span_id, span]));
const steps = new Set(conversationSteps(spans).map((span) => span.span_id));
const pageBoundary = spans.reduce((latest, span) => Math.max(latest, span.start_offset_ms), -Infinity);
const pending = spans.filter((span) => {
const missingDetail = steps.has(span.span_id) && !details.has(span.span_id);
const mayHaveLaterChildren = hasMoreSpans && span.start_offset_ms + span.duration_ms >= pageBoundary;
return missingDetail || mayHaveLaterChildren;
});
const initial = {
pending: new Set(pending.map((span) => span.span_id)),
ancestors: new Map(
spans.flatMap((span) =>
span.parent_span_id && byId.has(span.parent_span_id) ? [[span.span_id, span.parent_span_id] as const] : [],
),
),
};
const doublingPasses = Math.ceil(Math.log2(Math.max(1, spans.length)));
return Array.from({ length: doublingPasses }).reduce<typeof initial>((state) => {
if (!state.pending.size || !state.ancestors.size) return state;
return {
pending: new Set([
...state.pending,
...[...state.pending].flatMap((id) => {
const ancestor = state.ancestors.get(id);
return ancestor ? [ancestor] : [];
}),
]),
ancestors: new Map(
[...state.ancestors].flatMap(([id, ancestor]) => {
const next = state.ancestors.get(ancestor);
return next ? [[id, next] as const] : [];
}),
),
};
}, initial).pending;
}
function contentText(value: string, content?: UIContent): string {
if (content?.kind === "text") return content.text;
if (content?.kind === "fields")
@ -147,7 +189,7 @@ function conversationEvents(
steps: readonly Span[],
byId: ReadonlyMap<string, Span>,
details: ReadonlyMap<string, SpanDetail>,
complete: boolean,
{ complete, pendingBranches }: { complete: boolean; pendingBranches?: ReadonlySet<string> },
): ConversationEvent[] {
const missingIndex = steps.findIndex((span) => !details.has(span.span_id));
const loaded = missingIndex < 0 ? steps : steps.slice(0, missingIndex);
@ -170,7 +212,8 @@ function conversationEvents(
const start = { span, time: messageTime(span, loaded, details), output: false };
if (span.type === "tool" || (span.type !== "agent" && span.parent_span_id !== null)) return [start];
const end = ends.get(span.span_id)!;
return (complete && missingIndex < 0) || end < boundary ? [start, { span, time: end, output: true }] : [start];
const branchComplete = (complete && missingIndex < 0) || pendingBranches?.has(span.span_id) === false;
return branchComplete || end < boundary ? [start, { span, time: end, output: true }] : [start];
})
.sort(
(a, b) =>
@ -252,6 +295,7 @@ export function buildConversation(
spans: readonly Span[],
recordedDetails: ReadonlyMap<string, SpanDetail>,
complete: boolean,
pendingBranches?: ReadonlySet<string>,
): ConversationItem[] {
const details = claudeToolDetails(spans, recordedDetails);
const byId = new Map(spans.map((span) => [span.span_id, span]));
@ -259,7 +303,7 @@ export function buildConversation(
const completedOutputs = new Map<string, TraceMessage[]>();
const pendingCalls = new Map<string, TraceToolCall[]>();
const items: ConversationItem[] = [];
const events = conversationEvents(conversationSteps(spans), byId, details, complete);
const events = conversationEvents(conversationSteps(spans), byId, details, { complete, pendingBranches });
const branch = (span: Span): string => conversationBranch(span, byId);
for (const event of events) {
const { span } = event;
@ -354,6 +398,17 @@ export type ConversationGroup =
| { kind: "item"; item: ConversationItem }
| { kind: "branch"; id: string; name: string; children: ConversationGroup[] };
export function conversationBranchGroups(groups: ConversationGroup[], branchId: string | null): ConversationGroup[] {
if (branchId === null) return groups;
for (const group of groups) {
if (group.kind === "item") continue;
if (group.id === branchId) return group.children;
const nested = conversationBranchGroups(group.children, branchId);
if (nested.length) return nested;
}
return [];
}
export function groupConversation(items: readonly ConversationItem[], spans: readonly Span[]): ConversationGroup[] {
const byId = new Map(spans.map((span) => [span.span_id, span]));
const parentById = new Map(

View file

@ -0,0 +1,48 @@
import { useQueries, useQueryClient } from "@tanstack/react-query";
import { useCallback, useMemo, useState } from "react";
import { useTracesApi } from "../../api";
import type { Span, SpanDetail, Trace } from "../../types";
import { CONVERSATION_PAGE_SIZE, conversationSteps } from "./conversation";
export function useConversationDetails(trace: Trace, accessToken: string) {
const traces = useTracesApi(accessToken);
const queryClient = useQueryClient();
const [limit, setLimit] = useState(CONVERSATION_PAGE_SIZE);
const steps = useMemo(() => conversationSteps(trace.spans), [trace.spans]);
const visible = steps.slice(0, limit);
const { trace_id: traceId, trace_ref: traceRef } = trace.summary;
const spanQuery = useCallback(
(span: Span) => ({
queryKey: ["agentTraceSpan", traceId, traceRef, span.span_id, accessToken],
queryFn: (): Promise<SpanDetail> => traces.span(traceId, span.span_id, traceRef),
staleTime: Infinity,
retry: false as const,
retryOnMount: false,
}),
[traceId, traceRef, accessToken, traces],
);
const queries = useQueries({ queries: visible.map(spanQuery) });
const unresolvedIndex = queries.findIndex((query) => !query.isSuccess);
const loadedCount = unresolvedIndex < 0 ? queries.length : unresolvedIndex;
const details = new Map(
queries.slice(0, loadedCount).map((query, index) => [visible[index].span_id, query.data!] as const),
);
const loadMore = useCallback(
async (signal: AbortSignal) => {
const nextLimit = limit + CONVERSATION_PAGE_SIZE;
const batch = steps.slice(limit, nextLimit);
await Promise.all(batch.map((span) => queryClient.prefetchQuery(spanQuery(span))));
if (!signal.aborted) setLimit(nextLimit);
},
[limit, steps, queryClient, spanQuery],
);
return {
details,
entries: visible.map((span, index) => ({ span, query: queries[index] })),
complete: loadedCount === steps.length,
loading: queries.some((query) => query.isPending),
failed: queries.some((query) => query.isError),
hasMore: visible.length < steps.length,
loadMore,
};
}

View file

@ -10,7 +10,7 @@ import { TabsContent } from "@/components/ui/tabs";
import type { RunSelection } from "../../routing";
import type { Trace } from "../../types";
import { TraceConversation } from "../conversation/TraceConversation";
import { TraceConversation, type ConversationTracePaging } from "../conversation/TraceConversation";
import { DetailPane } from "../span/DetailPane";
import { SpanTree } from "../tree/SpanTree";
import type { TreeLayout } from "../tree/TreeRows";
@ -25,10 +25,11 @@ interface RunBodyProps {
selection: RunSelection;
embedded: boolean;
stale: boolean;
conversationPaging: ConversationTracePaging;
}
/** Tree + detail pane for one loaded run. Arrows move and fold steps; J/K also move unless the drawer owns them. */
export function RunBody({ trace, accessToken, selection, embedded, stale }: RunBodyProps) {
export function RunBody({ trace, accessToken, selection, embedded, stale, conversationPaging }: RunBodyProps) {
const { view, setView, stepQuery, setStepQuery, errorsOnly, setErrorsOnly } = selection;
const tree = useRunTree(trace, selection);
const [detailOpen, setDetailOpen] = useState(true);
@ -58,6 +59,7 @@ export function RunBody({ trace, accessToken, selection, embedded, stale }: RunB
<TraceConversation
trace={trace}
accessToken={accessToken}
paging={conversationPaging}
onOpenStep={(id) => {
select(id);
setView("steps");

View file

@ -158,6 +158,11 @@ function LoadedRun({
selection={selection}
embedded={embedded}
stale={switching}
conversationPaging={{
loading: isFetching,
failed: isError,
loadMore: fetchNextPage,
}}
/>
</Tabs>
);