diff --git a/litellm-rust/crates/traces-cache/src/cache.rs b/litellm-rust/crates/traces-cache/src/cache.rs index a5526a39963..f37db2c1c90 100644 --- a/litellm-rust/crates/traces-cache/src/cache.rs +++ b/litellm-rust/crates/traces-cache/src/cache.rs @@ -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 + ); + } } diff --git a/litellm-rust/crates/traces-cache/tests/read.rs b/litellm-rust/crates/traces-cache/tests/read.rs index 3f76d559a2b..18a83329107 100644 --- a/litellm-rust/crates/traces-cache/tests/read.rs +++ b/litellm-rust/crates/traces-cache/tests/read.rs @@ -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() { diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/TraceConversation.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/TraceConversation.integration.test.tsx index d39c486025d..884cd0d5304 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/TraceConversation.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/TraceConversation.integration.test.tsx @@ -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( + , + ); + 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(); + vi.mocked(agentTraceCall) + .mockResolvedValueOnce(first) + .mockReturnValueOnce(pending.promise) + .mockResolvedValue(second); + renderWithProviders( + , + ); + 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( + , + ); + 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(); + vi.mocked(agentTraceCall).mockResolvedValue({ ...trace, next_cursor: "next-page" }); + renderWithProviders( + , + ); + 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'"; diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/useConversationDetails.ts b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/useConversationDetails.ts index e94bb82cd98..e13b663a421 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/useConversationDetails.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/useConversationDetails.ts @@ -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), diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunHeader.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunHeader.tsx index 33fb073d0a9..f5ec196244f 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunHeader.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunHeader.tsx @@ -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 ( - {failed ? "Failed" : "Completed"} + {failed ? "Errors recorded" : "Recorded"} ); } @@ -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)
+ + { ); 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(); + 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(); + 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( + + + , + ); + 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((resolve) => { + setTimeout(() => resolve(updated), 1_000); + }), + ); + renderWithProviders(); + 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((resolve) => { + setTimeout(() => resolve(updated), 1_000); + }), + ); + renderWithProviders( + , + ); + 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(); + 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(); + 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"); }); diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx index d00481498f1..12ff750f95c 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx @@ -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) => { + 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 ( + {traceQuery.isRefetchError && ( +
+ Could not refresh this run. Previously received steps are still shown. + +
+ )} {(traceQuery.hasNextPage || failure) && ( void traceQuery.fetchNextPage()} - onRefresh={() => void refreshTrace()} + busy={manualRead || traceQuery.isFetching} + onLoadMore={() => readManually(() => traceQuery.fetchNextPage())} + onRefresh={() => readManually(refreshTrace)} /> )}