fix(ui): share one centered loading state across Lens traces and investigations

This commit is contained in:
Yujong Lee 2026-10-04 16:11:21 -07:00
parent b99c6d40d2
commit a0a275e486
7 changed files with 87 additions and 25 deletions

View file

@ -1,7 +1,8 @@
"use client"; "use client";
import { ArrowUpRight, Loader2, SearchX, TriangleAlert } from "lucide-react"; import { ArrowUpRight, SearchX, TriangleAlert } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { LoadingState } from "../ui/LoadingState";
import { StateMessage } from "../ui/StateMessage"; import { StateMessage } from "../ui/StateMessage";
import { ApiError } from "@/lib/http/client"; import { ApiError } from "@/lib/http/client";
@ -63,9 +64,7 @@ export function InvestigationError({ message, refresh }: { message: string; refr
export function InvestigationsLoading() { export function InvestigationsLoading() {
return ( return (
<StateMessage <LoadingState
role="status"
icon={<Loader2 className="size-5 animate-spin motion-reduce:animate-none" />}
title="Loading investigations…" title="Loading investigations…"
description="Fetching your investigations and their latest findings." description="Fetching your investigations and their latest findings."
/> />

View file

@ -311,6 +311,26 @@ it("runs with saved settings from Run now without opening setup, then accepts an
expect(sentBody(proxy.post, "/lens/lens/runs")).toEqual([{ agent_name: "billing", lookback_hours: 24 }]); expect(sentBody(proxy.post, "/lens/lens/runs")).toEqual([{ agent_name: "billing", lookback_hours: 24 }]);
}); });
it("shows a loading state until the investigation list arrives", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
let resolveList: (list: unknown) => void = () => {};
proxy.get.mockImplementation((path) => {
if (path === "/lens")
return new Promise((resolve) => {
resolveList = resolve;
});
return Promise.resolve({ traces: true, requests: false, data: [] });
});
renderWithProviders(withPreview(<InvestigationsView />, vi.fn()));
expect(await screen.findByRole("status", { name: "Loading investigations…" })).toBeVisible();
expect(screen.queryByRole("region", { name: "Get Lens running" })).not.toBeInTheDocument();
act(() => resolveList({ lenses: [], workers: [], tracing_enabled: true }));
expect(await screen.findByRole("region", { name: "Get Lens running" })).toBeVisible();
expect(screen.queryByRole("status", { name: "Loading investigations…" })).not.toBeInTheDocument();
});
it("offers the interactive demo without starting an investigation", async () => { it("offers the interactive demo without starting an investigation", async () => {
window.history.replaceState({}, "", "/lens/"); window.history.replaceState({}, "", "/lens/");
testQueryClient.clear(); testQueryClient.clear();

View file

@ -144,6 +144,25 @@ describe("RunView", () => {
expect(screen.getByLabelText("Keyboard shortcuts")).toHaveTextContent("↑/↓ step←/→ foldEsc close"); expect(screen.getByLabelText("Keyboard shortcuts")).toHaveTextContent("↑/↓ step←/→ foldEsc close");
}); });
it("shows a loading state until the first trace arrives", async () => {
let resolveTrace: (trace: Trace) => void = () => {};
vi.mocked(agentTraceCall).mockImplementation(
() =>
new Promise<Trace>((resolve) => {
resolveTrace = resolve;
}),
);
renderWithProviders(
<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} embedded />,
);
expect(screen.getByRole("status", { name: "Loading trace…" })).toBeVisible();
expect(screen.queryByTestId("run-view")).not.toBeInTheDocument();
act(() => resolveTrace(research));
expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent(traceDisplayName(research.summary));
expect(screen.queryByRole("status", { name: "Loading trace…" })).not.toBeInTheDocument();
});
it("keeps the current run on screen, inert, while an unvisited run loads in the drawer", async () => { it("keeps the current run on screen, inert, while an unvisited run loads in the drawer", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let resolveSwarm: (trace: Trace) => void = () => {}; let resolveSwarm: (trace: Trace) => void = () => {};
@ -161,7 +180,7 @@ describe("RunView", () => {
rerender(<RoutedRunView traceId={swarm.summary.trace_id} {...props} />); rerender(<RoutedRunView traceId={swarm.summary.trace_id} {...props} />);
await waitFor(() => expect(screen.getByTestId("run-view")).toHaveAttribute("aria-busy", "true")); await waitFor(() => expect(screen.getByTestId("run-view")).toHaveAttribute("aria-busy", "true"));
expect(screen.queryByRole("status", { name: "Loading trace" })).not.toBeInTheDocument(); expect(screen.queryByRole("status", { name: "Loading trace…" })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(traceDisplayName(research.summary)); expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(traceDisplayName(research.summary));
await user.keyboard("{ArrowDown}"); await user.keyboard("{ArrowDown}");
expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", root); expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", root);

View file

@ -7,10 +7,10 @@ import { ErrorBoundary } from "react-error-boundary";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs } from "@/components/ui/tabs"; import { Tabs } from "@/components/ui/tabs";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { cn } from "@/lib/cva.config"; import { cn } from "@/lib/cva.config";
import { useTracesApi } from "../../api"; import { useTracesApi } from "../../api";
import { LoadingState } from "../../../ui/LoadingState";
import { classifyTraceReadFailure, traceReadRetry, traceReadRetryDelay } from "../../list/traceReadFailure"; import { classifyTraceReadFailure, traceReadRetry, traceReadRetryDelay } from "../../list/traceReadFailure";
import { type RunSelection, traceKey } from "../../routing"; import { type RunSelection, traceKey } from "../../routing";
import type { Trace } from "../../types"; import type { Trace } from "../../types";
@ -32,22 +32,8 @@ function selectedSpanMissing(trace: Trace, spanId: string | null): boolean {
return Boolean(spanId && !trace.spans.some((span) => span.span_id === spanId)); return Boolean(spanId && !trace.spans.some((span) => span.span_id === spanId));
} }
function RunLoading({ embedded }: { embedded: boolean }) { function RunLoading() {
return ( return <LoadingState title="Loading trace…" description="Fetching this run and its steps." />;
<div
role="status"
aria-label="Loading trace"
className={embedded ? "flex flex-col gap-3 p-4" : "flex h-[60vh] items-center justify-center"}
>
{embedded ? (
[72, 48, 88, 60, 80].map((w) => (
<div key={w} className="h-4 animate-pulse rounded bg-trace-row-hover" style={{ width: `${w}%` }} />
))
) : (
<UiLoadingSpinner className="size-6 text-muted-foreground" />
)}
</div>
);
} }
function RunLoadError({ error, onBack, onRetry }: { error: unknown; onBack: () => void; onRetry: () => void }) { function RunLoadError({ error, onBack, onRetry }: { error: unknown; onBack: () => void; onRetry: () => void }) {
@ -85,7 +71,7 @@ export function RunView(props: RunViewProps) {
<RunLoadError error={error} onBack={props.onBack} onRetry={resetErrorBoundary} /> <RunLoadError error={error} onBack={props.onBack} onRetry={resetErrorBoundary} />
)} )}
> >
<Suspense fallback={<RunLoading embedded={props.embedded ?? false} />}> <Suspense fallback={<RunLoading />}>
<LoadedRun key={shownKey} {...props} traceId={traceId} traceRef={traceRef} switching={switching} /> <LoadedRun key={shownKey} {...props} traceId={traceId} traceRef={traceRef} switching={switching} />
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>

View file

@ -51,6 +51,26 @@ describe("AgentTracesTable empty state", () => {
}); });
}); });
describe("AgentTracesTable loading state", () => {
it("announces the first page load instead of an empty table", () => {
render(
inList(
<AgentTracesTable
traces={[]}
isLoading
error={null}
hasMore={false}
onLoadMore={vi.fn()}
rangeEmpty
onSetUpTracing={vi.fn()}
/>,
),
);
expect(screen.getByRole("status", { name: "Loading runs…" })).toBeVisible();
expect(screen.queryByText(/No runs/)).not.toBeInTheDocument();
});
});
describe("AgentTracesTable virtualization", () => { describe("AgentTracesTable virtualization", () => {
const template = (traceList as TracePage).data[0] as TraceSummary; const template = (traceList as TracePage).data[0] as TraceSummary;
const manyRuns: TraceSummary[] = Array.from({ length: 500 }, (_, i) => ({ const manyRuns: TraceSummary[] = Array.from({ length: 500 }, (_, i) => ({

View file

@ -12,6 +12,7 @@ import { formatActivityTimestamp, formatRunTimestamp, localTimeZoneAbbreviation
import { SpanIcon } from "../ui/SpanIcon"; import { SpanIcon } from "../ui/SpanIcon";
import { StatusMark } from "../ui/StatusMark"; import { StatusMark } from "../ui/StatusMark";
import { FrameworkLogo, traceFramework } from "../ui/TraceFramework"; import { FrameworkLogo, traceFramework } from "../ui/TraceFramework";
import { LoadingState } from "../../ui/LoadingState";
import type { TraceSummary } from "../types"; import type { TraceSummary } from "../types";
import { traceRefOf } from "../routing"; import { traceRefOf } from "../routing";
import { fmtMs, previewText, traceDisplayName, traceAgentNames } from "../utils"; import { fmtMs, previewText, traceDisplayName, traceAgentNames } from "../utils";
@ -215,7 +216,7 @@ export function AgentTracesTable({
}; };
const table = useReactTable(tableOptions); const table = useReactTable(tableOptions);
return ( return (
<InspectorTable.Root table={table} data-testid="runs-table"> <InspectorTable.Root table={table} data-testid="runs-table" className="flex flex-col">
<InspectorTable.Grid aria-label="Agent runs" aria-busy={isFetching} className="min-w-[900px] text-xs"> <InspectorTable.Grid aria-label="Agent runs" aria-busy={isFetching} className="min-w-[900px] text-xs">
<InspectorTable.Header /> <InspectorTable.Header />
<InspectorTable.Body<TraceSummary> <InspectorTable.Body<TraceSummary>
@ -232,7 +233,7 @@ export function AgentTracesTable({
)} )}
</InspectorTable.Body> </InspectorTable.Body>
</InspectorTable.Grid> </InspectorTable.Grid>
{isLoading && <div className="py-16 text-center text-xs text-muted-foreground">Loading runs…</div>} {isLoading && <LoadingState title="Loading runs…" description="Fetching agent runs for this time range." />}
{error && ( {error && (
<div role="alert" className="flex items-center justify-center gap-3 py-6 text-xs text-muted-foreground"> <div role="alert" className="flex items-center justify-center gap-3 py-6 text-xs text-muted-foreground">
<span> <span>

View file

@ -0,0 +1,17 @@
import { Loader2 } from "lucide-react";
import { StateMessage, type StateMessageProps } from "./StateMessage";
type LoadingStateProps = Omit<StateMessageProps, "role" | "icon" | "tone">;
export function LoadingState({ title, ...props }: LoadingStateProps) {
return (
<StateMessage
role="status"
aria-label={title}
icon={<Loader2 className="size-5 animate-spin motion-reduce:animate-none" />}
title={title}
{...props}
/>
);
}