fix(lens): refresh open traces without claiming session completion (#44900)

* fix(lens): refresh open traces without claiming session completion

* fix(lens): cancel paused refreshes and distinguish refresh failures

* style(lens): format live refresh regressions

* fix(lens): preserve manual reads when pausing live updates

* fix(tracing): preserve optional provider evidence in fixture replay

* fix(tracing): keep copied provider identities consistent

* fix(lens): refresh resumed native sessions and retain paging

* fix(lens): serialize conversation paging with refresh

* fix(lens): refresh recorded content with trace details

* fix(lens): refresh content using resolved trace references

* fix(lens): preserve content through refresh failures

* fix(lens): serialize conversation paging with content refresh
This commit is contained in:
moe-berri 2026-10-06 15:31:45 -07:00 • committed by GitHub
parent 191967c207
commit d181bc7b80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 458 additions and 25 deletions

View file

@ -58,9 +58,8 @@ impl SnapshotKey {
}
}
/// How long a read result stays reusable: traces still receiving spans, or read with spend
/// unavailable, are re-read after `LIVE_TTL`; traces quiet for `SETTLED_AFTER_MS` are kept for
/// `SETTLED_TTL`.
/// Native sessions can resume without a terminal record, so their reads retain `LIVE_TTL`.
/// Other traces with known spend settle after `SETTLED_AFTER_MS` of inactivity.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Freshness {
Live,
@ -69,6 +68,12 @@ pub enum Freshness {
impl Freshness {
pub fn of(rows: &[TraceSpansRow], spend_known: bool, snapshot_ms: u64) -> Self {
if rows
.iter()
.any(|row| matches!(row.framework.as_str(), "claude-code" | "claude-agent-sdk"))
{
return Self::Live;
}
let last_end_ms = rows
.iter()
.map(|row| row.start_ns.saturating_add_unsigned(row.duration_ns) / 1_000_000)
@ -393,4 +398,18 @@ mod tests {
expected
);
}
#[rstest]
#[case::claude_code("claude-code")]
#[case::claude_agent_sdk("claude-agent-sdk")]
fn idle_native_sessions_remain_live(#[case] framework: &str) {
let native = TraceSpansRow {
framework: framework.into(),
..row("native")
};
assert_eq!(
Freshness::of(&[row("root"), native], true, LAST_END_MS + SETTLED_AFTER_MS),
Freshness::Live
);
}
}

View file

@ -706,6 +706,54 @@ async fn failed_reads_are_not_cached() {
assert_eq!(store.calls(Operation::TraceSpans), 2);
}
#[rstest]
#[case::claude_code("claude-code")]
#[case::claude_agent_sdk("claude-agent-sdk")]
#[tokio::test]
async fn resumed_native_sessions_refresh_after_live_ttl(#[case] framework: &str) {
let original = TraceSpansRow {
framework: framework.into(),
..span(0)
};
let store = FakeStore::with_spans("ref", vec![original.clone()]);
let reader = TraceReader::new(usize::MAX);
let access = access();
let first = reader
.get_trace(&store, &access, "trace", "ref")
.await
.unwrap()
.unwrap();
assert_eq!(first.spans.len(), 1);
store.state.lock().unwrap().trace_spans.insert(
"ref".into(),
vec![
original,
TraceSpansRow {
start_ns: now_ns(),
..span(1)
},
],
);
let cached = reader
.get_trace(&store, &access, "trace", "ref")
.await
.unwrap()
.unwrap();
assert_eq!(cached.spans.len(), 1);
assert_eq!(store.calls(Operation::TraceSpans), 1);
tokio::time::sleep(LIVE_TTL + Duration::from_millis(200)).await;
let resumed = reader
.get_trace(&store, &access, "trace", "ref")
.await
.unwrap()
.unwrap();
assert_eq!(resumed.spans.len(), 2);
assert_eq!(store.calls(Operation::TraceSpans), 2);
assert_eq!(first.spans.len(), 1);
}
#[rstest]
#[tokio::test]
async fn listed_runs_are_read_once_until_a_live_run_expires() {

View file

@ -46,6 +46,31 @@ describe("TraceConversation", () => {
.mockImplementation(async (_token, _trace, id) => (id === "root" ? rootDetail : { ...toolDetail, span_id: id }));
});
it.each([false, true])("refreshes unchanged spans without hiding loaded content on failure (%s)", async (failed) => {
const user = userEvent.setup();
vi.mocked(agentTraceCall).mockResolvedValue({
...trace,
summary: { ...trace.summary, trace_ref: "resolved-reference" },
});
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("The release is ready")).toBeVisible();
vi.mocked(agentTraceSpanCall).mockImplementation(async (_token, _trace, id) => {
if (failed) throw new Error("content refresh failed");
return id === "root" ? { ...rootDetail, output: "Updated final answer" } : toolDetail;
});
await user.click(screen.getByRole("button", { name: "Refresh run" }));
if (failed) {
expect(await screen.findAllByRole("button", { name: "Retry step" })).toHaveLength(2);
expect(screen.getByText("The release is ready")).toBeVisible();
} else {
expect(await screen.findByText("Updated final answer")).toBeVisible();
expect(screen.queryByText("The release is ready")).not.toBeInTheDocument();
}
});
it("switches to a readable transcript and opens the exact tool step from it", async () => {
const user = userEvent.setup();
renderWithProviders(
@ -70,6 +95,89 @@ describe("TraceConversation", () => {
expect(screen.getByRole("heading", { name: "read_file" })).toBeVisible();
});
it("waits for an in-flight refresh before requesting another conversation page", async () => {
const user = userEvent.setup();
const first = { ...trace, spans: [root], next_cursor: "old-page" };
const refreshed = { ...first, next_cursor: "fresh-page" };
const second = { ...trace, spans: [tool], next_cursor: null };
const pending = Promise.withResolvers<Trace>();
vi.mocked(agentTraceCall)
.mockResolvedValueOnce(first)
.mockReturnValueOnce(pending.promise)
.mockResolvedValue(second);
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("Read the release notes")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Refresh run" }));
const more = screen.getByRole("button", { name: "Load next 20 entries" });
expect(more).toBeDisabled();
await user.click(more);
expect(agentTraceCall).toHaveBeenCalledTimes(2);
await act(async () => pending.resolve(refreshed));
await waitFor(() => expect(more).toBeEnabled());
await user.click(more);
expect(await screen.findByRole("button", { name: "Expand read_file tool call" })).toBeVisible();
expect(vi.mocked(agentTraceCall).mock.calls.map((call) => call[3])).toEqual([null, null, "fresh-page"]);
});
it("can load the next conversation page after a refresh fails without invalidating loaded content", async () => {
const user = userEvent.setup();
const first = { ...trace, spans: [root], next_cursor: "next-page" };
const second = { ...trace, spans: [tool], next_cursor: null };
vi.mocked(agentTraceCall)
.mockResolvedValueOnce(first)
.mockRejectedValueOnce(new Error("refresh unavailable"))
.mockResolvedValue(second);
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("Read the release notes")).toBeVisible();
const contentReads = vi.mocked(agentTraceSpanCall).mock.calls.length;
vi.mocked(agentTraceSpanCall).mockRejectedValue(new Error("content unavailable"));
await user.click(screen.getByRole("button", { name: "Refresh run" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Previously received steps are still shown");
expect(agentTraceSpanCall).toHaveBeenCalledTimes(contentReads);
vi.mocked(agentTraceSpanCall).mockImplementation(async (_token, _trace, id) =>
id === "root" ? rootDetail : toolDetail,
);
const more = screen.getByRole("button", { name: "Load next 20 entries" });
expect(more).toBeEnabled();
await user.click(more);
expect(await screen.findByRole("button", { name: "Expand read_file tool call" })).toBeVisible();
expect(vi.mocked(agentTraceCall).mock.calls.map((call) => call[3])).toEqual([null, null, "next-page"]);
expect(screen.queryByText(/Could not load more conversation entries/)).not.toBeInTheDocument();
});
it("keeps refresh busy until content finishes when live updates pause", async () => {
const user = userEvent.setup();
const pending = Promise.withResolvers<SpanDetail>();
vi.mocked(agentTraceCall).mockResolvedValue({ ...trace, next_cursor: "next-page" });
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("The release is ready")).toBeVisible();
vi.mocked(agentTraceSpanCall).mockImplementation(async (_token, _trace, id) =>
id === "root" ? pending.promise : toolDetail,
);
const refresh = screen.getByRole("button", { name: "Refresh run" });
await user.click(refresh);
await waitFor(() => expect(testQueryClient.isFetching({ queryKey: ["agentTrace"] })).toBe(0));
expect(refresh).toBeDisabled();
const more = screen.getByRole("button", { name: "Load next 20 entries" });
expect(more).toBeDisabled();
await user.click(refresh);
await user.click(screen.getByRole("button", { name: "Live updates" }));
await act(async () => pending.resolve({ ...rootDetail, output: "Updated final answer" }));
expect(await screen.findByText("Updated final answer")).toBeVisible();
await waitFor(() => expect(refresh).toBeEnabled());
expect(more).toBeEnabled();
expect(agentTraceCall).toHaveBeenCalledTimes(2);
});
it("renders a failed shell exchange in both views and preserves its raw result", async () => {
const user = userEvent.setup();
const command = "npm test -- checkout\nprintf 'finished\\n'";

View file

@ -22,7 +22,7 @@ export function useConversationDetails(trace: Trace, accessToken: string) {
[traceId, traceRef, accessToken, traces],
);
const queries = useQueries({ queries: visible.map(spanQuery) });
const unresolvedIndex = queries.findIndex((query) => !query.isSuccess);
const unresolvedIndex = queries.findIndex((query) => query.data === undefined);
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),

View file

@ -1,6 +1,6 @@
"use client";
import { ArrowLeft, Check, Copy, Link } from "lucide-react";
import { ArrowLeft, Check, Copy, Link, RefreshCw } from "lucide-react";
import { useState } from "react";
import { useTimeout } from "usehooks-ts";
@ -52,13 +52,14 @@ function Stat({ label, value, error = false }: { label: string; value: string; e
function StatusPill({ failed }: { failed: boolean }) {
return (
<span
title="Status of received spans. More spans may still arrive."
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-medium",
failed ? "bg-destructive/10 text-destructive" : "bg-trace-ok text-trace-ok-glyph",
)}
>
<span className={cn("size-1.5 rounded-full", failed ? "bg-destructive" : "bg-trace-ok-glyph")} />
{failed ? "Failed" : "Completed"}
{failed ? "Errors recorded" : "Recorded"}
</span>
);
}
@ -83,10 +84,25 @@ interface RunHeaderProps {
handoff: TraceHandoff;
onBack: () => void;
embedded: boolean;
refreshing: boolean;
onRefresh: () => void;
live: boolean;
canLive: boolean;
onLiveChange: () => void;
}
/** Run identity, view switch and totals in two tight rows. */
export function RunHeader({ trace, handoff, onBack, embedded }: RunHeaderProps) {
export function RunHeader({
trace,
handoff,
onBack,
embedded,
refreshing,
onRefresh,
live,
canLive,
onLiveChange,
}: RunHeaderProps) {
const { summary } = trace;
const failed = summary.status === "error";
const cost = runCost(summary);
@ -113,6 +129,20 @@ export function RunHeader({ trace, handoff, onBack, embedded }: RunHeaderProps)
</TabsTrigger>
</TabsList>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="xs"
aria-pressed={live}
disabled={!canLive}
onClick={onLiveChange}
aria-label="Live updates"
>
Live
</Button>
<Button variant="outline" size="xs" disabled={refreshing} onClick={onRefresh} aria-label="Refresh run">
<RefreshCw className={cn("size-3", refreshing && "animate-spin")} />
Refresh
</Button>
<CopyButton
label="Copy link"
icon={Link}

View file

@ -14,7 +14,7 @@ import { RunView } from "./RunView";
import { initialRunSelection } from "./useRunTree";
import { tickLabel, timeTicks } from "../tree/timeline";
import { traceShareUrl, useOpenTraceRouting } from "../../routing";
import { agentHandoffText } from "../../api";
import { agentHandoffText, liveTracesApi, TracesApiContext } from "../../api";
import type { Span } from "../../types";
import type { Trace } from "../../types";
import { traceDisplayName } from "../../utils";
@ -80,7 +80,8 @@ describe("RunView", () => {
);
expect(header).toHaveTextContent("Duration 40.20s");
expect(header).toHaveTextContent(`Steps ${research.summary.span_count}`);
expect(header).not.toHaveTextContent("failed");
expect(header).toHaveTextContent("Recorded");
expect(header).not.toHaveTextContent("Completed");
});
it("shows the agent name with the SDK logo in the run header instead of the generic agent icon", async () => {
@ -361,7 +362,7 @@ describe("RunView", () => {
expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(2);
});
it("keeps a loaded snapshot on focus and reconnect", async () => {
it("refreshes a stale run on focus and reconnect without losing its selected step", async () => {
testQueryClient.setQueryDefaults(["agentTrace"], { refetchOnWindowFocus: true, refetchOnReconnect: true });
vi.mocked(agentTraceCall).mockReset();
renderRun(research);
@ -374,10 +375,200 @@ describe("RunView", () => {
onlineManager.setOnline(true);
});
await waitFor(() => expect(testQueryClient.isFetching()).toBe(0));
expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(1);
expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(2);
expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", rootSpanId(research));
expect(screen.getByRole("tree", { name: "Spans in time order" })).toHaveTextContent(research.spans[0].name);
});
it("refreshes new spans from the header while preserving the selected step", async () => {
vi.mocked(agentTraceCall).mockReset();
vi.mocked(agentTraceCall)
.mockResolvedValueOnce(research)
.mockResolvedValue({
...research,
summary: { ...research.summary, span_count: research.summary.span_count + 1 },
spans: [
...research.spans,
{
...research.spans[0],
span_id: "new-step",
name: "newly received step",
parent_span_id: rootSpanId(research),
type: "tool",
},
],
});
renderWithProviders(<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />);
expect(await screen.findByTestId("detail-pane")).toHaveAttribute("data-row-id", rootSpanId(research));
fireEvent.click(screen.getByRole("button", { name: "Refresh run" }));
expect(await screen.findByText("newly received step")).toBeVisible();
expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${research.summary.span_count + 1}`);
expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", rootSpanId(research));
});
it("receives a growing run on the polling interval", async () => {
vi.useFakeTimers();
try {
vi.mocked(agentTraceCall).mockReset();
vi.mocked(agentTraceCall)
.mockResolvedValueOnce(research)
.mockResolvedValue({
...research,
summary: { ...research.summary, span_count: research.summary.span_count + 7 },
});
renderWithProviders(<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />);
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${research.summary.span_count}`);
await act(async () => {
await vi.advanceTimersByTimeAsync(30_100);
});
expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${research.summary.span_count + 7}`);
expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", rootSpanId(research));
} finally {
vi.useRealTimers();
}
});
it("keeps fixed snapshot sources out of live mode", async () => {
vi.mocked(agentTraceCall).mockResolvedValue(research);
renderWithProviders(
<TracesApiContext.Provider value={{ ...liveTracesApi("sk-test"), live: false }}>
<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />
</TracesApiContext.Provider>,
);
expect(await screen.findByRole("button", { name: "Live updates" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Live updates" })).toHaveAttribute("aria-pressed", "false");
expect(screen.getByRole("button", { name: "Refresh run" })).toBeEnabled();
});
it("can pause live updates without disabling manual refresh", async () => {
vi.mocked(agentTraceCall).mockReset();
renderRun(research);
await screen.findByTestId("detail-pane");
fireEvent.click(screen.getByRole("button", { name: "Live updates" }));
expect(screen.getByRole("button", { name: "Live updates" })).toHaveAttribute("aria-pressed", "false");
await testQueryClient.invalidateQueries({ queryKey: ["agentTrace"], refetchType: "none" });
await act(async () => {
focusManager.setFocused(false);
onlineManager.setOnline(false);
focusManager.setFocused(true);
onlineManager.setOnline(true);
});
expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Refresh run" }));
await waitFor(() => expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(2));
});
it("cancels an in-flight refresh and stops interval requests when live updates pause", async () => {
vi.useFakeTimers();
try {
const updated = { ...research, summary: { ...research.summary, span_count: research.summary.span_count + 7 } };
vi.mocked(agentTraceCall).mockReset();
vi.mocked(agentTraceCall)
.mockResolvedValueOnce(research)
.mockImplementationOnce(
() =>
new Promise<Trace>((resolve) => {
setTimeout(() => resolve(updated), 1_000);
}),
);
renderWithProviders(<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />);
await act(async () => {
await vi.advanceTimersByTimeAsync(30_100);
});
expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(2);
fireEvent.click(screen.getByRole("button", { name: "Live updates" }));
await act(async () => {
await vi.advanceTimersByTimeAsync(60_100);
});
expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${research.summary.span_count}`);
expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it.each(["Refresh run", "Load more steps"])(
"keeps an in-flight manual %s when live updates pause",
async (action) => {
vi.useFakeTimers();
try {
const first: Trace = { ...research, next_cursor: "next-page" };
const updated: Trace = {
...research,
summary: { ...research.summary, span_count: research.summary.span_count + 7 },
next_cursor: null,
};
vi.mocked(agentTraceCall).mockReset();
vi.mocked(agentTraceCall)
.mockResolvedValueOnce(first)
.mockImplementationOnce(
() =>
new Promise<Trace>((resolve) => {
setTimeout(() => resolve(updated), 1_000);
}),
);
renderWithProviders(
<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
fireEvent.click(screen.getByRole("button", { name: action }));
fireEvent.click(screen.getByRole("button", { name: "Live updates" }));
await act(async () => {
await vi.advanceTimersByTimeAsync(60_100);
});
expect(screen.queryByRole("button", { name: "Load more steps" })).not.toBeInTheDocument();
expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(2);
if (action === "Refresh run") {
expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${updated.summary.span_count}`);
}
} finally {
vi.useRealTimers();
}
},
);
it("retries a failed refresh without calling the next-page operation", async () => {
vi.mocked(agentTraceCall).mockReset();
vi.mocked(agentTraceCall)
.mockResolvedValueOnce(research)
.mockRejectedValueOnce(new Error("refresh unavailable"))
.mockResolvedValue(research);
renderWithProviders(<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />);
await screen.findByTestId("detail-pane");
fireEvent.click(screen.getByRole("button", { name: "Refresh run" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Previously received steps are still shown");
expect(screen.queryByText(/could not load more/i)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry refresh" }));
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
expect(vi.mocked(agentTraceCall).mock.calls.map((call) => call[3])).toEqual([null, null, null]);
});
it("refreshes paginated runs using the newly returned cursor", async () => {
const first = { ...research, spans: research.spans.slice(0, 1), next_cursor: "old-page" };
const second = { ...research, spans: research.spans.slice(1), next_cursor: null };
vi.mocked(agentTraceCall).mockReset();
vi.mocked(agentTraceCall)
.mockResolvedValueOnce(first)
.mockResolvedValueOnce(second)
.mockResolvedValueOnce({ ...first, next_cursor: "new-page" })
.mockImplementationOnce(async (_token, _id, _ref, cursor) => {
if (cursor !== "new-page") throw new Error("stale cursor");
return second;
});
renderWithProviders(<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />);
fireEvent.click(await screen.findByRole("button", { name: "Load more steps" }));
await waitFor(() => expect(screen.queryByRole("button", { name: "Load more steps" })).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: "Refresh run" }));
await waitFor(() => expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(4));
expect(vi.mocked(agentTraceCall).mock.calls.map((call) => call[3])).toEqual([null, "old-page", null, "new-page"]);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("keeps a way back to the runs table when a run fails to load", async () => {
const user = userEvent.setup();
const onBack = vi.fn();
@ -457,7 +648,7 @@ describe("RunView", () => {
it("distinguishes a completed run with recovered step errors from a failed run", async () => {
renderRun({ ...research, summary: { ...research.summary, status: "ok", error_count: 2 } });
const header = await screen.findByRole("banner");
expect(header).toHaveTextContent("Completed");
expect(header).toHaveTextContent("Recorded");
expect(header).toHaveTextContent("Step errors 2");
expect(header).not.toHaveTextContent("Failed");
});

View file

@ -2,7 +2,7 @@
import { QueryErrorResetBoundary, useQueryClient, useSuspenseInfiniteQuery } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import { Suspense, useDeferredValue, useEffect, useMemo } from "react";
import { Suspense, useDeferredValue, useEffect, useMemo, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { LoadingState } from "@/components/shared/LoadingState";
@ -91,6 +91,8 @@ function LoadedRun({
}: RunViewProps & { switching: boolean }) {
const traces = useTracesApi(accessToken);
const queryClient = useQueryClient();
const [live, setLive] = useState(traces.live);
const [manualRead, setManualRead] = useState(false);
const queryKey = ["agentTrace", traceId, traceRef, accessToken];
const traceQueryOptions = {
queryKey,
@ -98,15 +100,37 @@ function LoadedRun({
initialPageParam: null as string | null,
getNextPageParam: (lastPage: Trace) => lastPage.next_cursor ?? undefined,
staleTime: 30_000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
refetchOnWindowFocus: live,
refetchOnReconnect: live,
refetchOnMount: true,
refetchInterval: live ? 30_000 : (false as const),
retry: traceReadRetry,
retryDelay: traceReadRetryDelay,
};
const traceQuery = useSuspenseInfiniteQuery(traceQueryOptions);
const refreshTrace = () => queryClient.resetQueries({ queryKey, exact: true });
const failure = traceQuery.error ? classifyTraceReadFailure(traceQuery.error) : null;
const failure = traceQuery.isFetchNextPageError ? classifyTraceReadFailure(traceQuery.error) : null;
const readManually = (read: () => Promise<unknown>) => {
if (manualRead) return;
setManualRead(true);
void read().finally(() => setManualRead(false));
};
const refreshRun = () =>
readManually(async () => {
const refreshed = await traceQuery.refetch();
if (refreshed.isError) return;
const contentRef = refreshed.data?.pages[0].summary.trace_ref ?? traceRef;
await queryClient.invalidateQueries({
queryKey: ["agentTraceSpan", traceId, contentRef],
predicate: (query) => query.queryKey.at(-1) === accessToken,
});
});
const toggleLive = () => {
if (live && !manualRead && !traceQuery.isFetchingNextPage) {
void queryClient.cancelQueries({ queryKey, exact: true });
}
setLive((enabled) => !enabled);
};
const trace = useMemo(() => {
const pages = traceQuery.data.pages;
return {
@ -116,11 +140,11 @@ function LoadedRun({
};
}, [traceQuery.data]);
const seekingSpan = !switching && selectedSpanMissing(trace, selection.spanId);
const { hasNextPage, isFetching, isError, fetchNextPage } = traceQuery;
const { hasNextPage, isFetching, isFetchNextPageError, fetchNextPage } = traceQuery;
const canSeek = seekingSpan && hasNextPage;
useEffect(() => {
if (canSeek && !isFetching && !isError) void fetchNextPage();
}, [canSeek, isFetching, isError, fetchNextPage]);
if (canSeek && !isFetching && !isFetchNextPageError) void fetchNextPage();
}, [canSeek, isFetching, isFetchNextPageError, fetchNextPage]);
return (
<Tabs
@ -140,15 +164,28 @@ function LoadedRun({
handoff={traces.handoff(trace.summary.trace_id, null, trace.summary.trace_ref)}
onBack={onBack}
embedded={embedded}
refreshing={manualRead || traceQuery.isFetching}
onRefresh={refreshRun}
live={live}
canLive={traces.live}
onLiveChange={toggleLive}
/>
{traceQuery.isRefetchError && (
<div role="alert" className="flex items-center gap-3 border-b p-3 text-xs text-muted-foreground">
Could not refresh this run. Previously received steps are still shown.
<Button variant="outline" size="sm" disabled={manualRead || traceQuery.isFetching} onClick={refreshRun}>
Retry refresh
</Button>
</div>
)}
{(traceQuery.hasNextPage || failure) && (
<PagingBanner
loaded={trace.spans.length}
total={trace.summary.span_count}
failure={failure}
busy={traceQuery.isFetching}
onLoadMore={() => void traceQuery.fetchNextPage()}
onRefresh={() => void refreshTrace()}
busy={manualRead || traceQuery.isFetching}
onLoadMore={() => readManually(() => traceQuery.fetchNextPage())}
onRefresh={() => readManually(refreshTrace)}
/>
)}
<RunBody
@ -159,8 +196,8 @@ function LoadedRun({
embedded={embedded}
stale={switching}
conversationPaging={{
loading: isFetching,
failed: isError,
loading: manualRead || traceQuery.isFetching,
failed: isFetchNextPageError,
loadMore: fetchNextPage,
}}
/>