diff --git a/desktop/src/load-more-stale.test.tsx b/desktop/src/load-more-stale.test.tsx
new file mode 100644
index 0000000..97cb80d
--- /dev/null
+++ b/desktop/src/load-more-stale.test.tsx
@@ -0,0 +1,185 @@
+import {
+ render,
+ screen,
+ act,
+ waitFor,
+ fireEvent,
+} from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import App from "./App";
+import { api } from "./api";
+import type { Span, RecentTrace, SpanPage } from "./domain";
+
+vi.mock("./api", () => ({
+ api: { status: vi.fn(), read: vi.fn(), note: vi.fn() },
+}));
+
+const runId = "run-1";
+const traceId = "11111111111111111111111111111111";
+
+const session = {
+ id: "project-1",
+ title: "catalog",
+ project: "/project/catalog",
+ created_ms: 1,
+ expectations: [],
+};
+
+// The run is actively capturing ("collecting"), so the live span set is still
+// growing: store.rs:188 initializes capture_status "collecting" at run start
+// and analysis.rs:111 treats "settled" as the completeness criterion.
+const run = {
+ id: runId,
+ session_id: session.id,
+ label: "Baseline",
+ command: "unit tests",
+ revision: "abc dirty",
+ started_ms: 1,
+ finished_ms: null,
+ exit_code: 0,
+ test_status: "passed",
+ capture_status: "collecting",
+ issues: [],
+};
+
+const report = {
+ run,
+ span_count: 250,
+ trace_count: 1,
+ issues: [],
+ operations: [],
+ operations_total: 0,
+ verification: [],
+};
+
+function span(i: number, tid = traceId): Span {
+ const id = `s${String(i).padStart(6, "0")}`;
+ return {
+ trace_id: tid,
+ span_id: id,
+ parent_span_id: i === 0 ? "" : `s${String(i - 1).padStart(6, "0")}`,
+ name: `span-${i}`,
+ service: "catalog",
+ start_ns: String(10n ** 20n + BigInt(i) * 1000n),
+ end_ns: String(10n ** 20n + BigInt(i) * 1000n + 100n),
+ error: false,
+ dropped: false,
+ raw: {},
+ resource: {},
+ scope: {},
+ };
+}
+
+const trace: RecentTrace = {
+ run_id: runId,
+ trace_id: traceId,
+ root_span_id: "s000000",
+ name: "trace",
+ services: ["catalog"],
+ start_ns: String(10n ** 20n),
+ duration_ns: "100",
+ span_count: 250,
+ errors: 0,
+};
+
+const inspected = {
+ span: span(0),
+ uncovered_recorded_ns: null,
+ coverage_note: "",
+};
+
+// First page is fetched when the trace has 250 spans: total reflects the live
+// COUNT(*) at fetch time, and next_offset stays open at 200.
+function firstPage(tid: string): SpanPage {
+ return {
+ spans: Array.from({ length: 200 }, (_, i) => span(i, tid)),
+ total: 250,
+ next_offset: 200,
+ };
+}
+
+// By the time the user clicks "Load more" the trace has grown to 600 spans.
+// The offset=200 page reports the authoritative live COUNT (`total: 600`) and
+// keeps paging open at 400.
+function grownPage(tid: string): SpanPage {
+ return {
+ spans: Array.from({ length: 200 }, (_, i) => span(200 + i, tid)),
+ total: 600,
+ next_offset: 400,
+ };
+}
+
+beforeEach(() => {
+ vi.mocked(api.status).mockResolvedValue({
+ url: "http://127.0.0.1:4318",
+ version: "0.1.0",
+ data_dir: "/data",
+ });
+ vi.mocked(api.read).mockImplementation(async (path: string) => {
+ // A non-matching search excludes the selected trace from the polled
+ // recent-traces list, mirroring store.rs:397's HAVING filter.
+ if (path === "traces?limit=200&q=xyz")
+ return { traces: [], total: 0, next_offset: null };
+ if (path.startsWith("traces?") || path.includes("/traces?"))
+ return { traces: [trace], total: 1, next_offset: null };
+ if (path === `sessions/${session.id}`)
+ return { session, runs: [run], notes: [] };
+ if (path === `runs/${runId}`) return report;
+ if (path.endsWith("/findings"))
+ return { findings: [], limitations: [], analyzed_spans: 0 };
+ if (path.includes("/spans/")) return inspected;
+ if (path.includes("offset=")) return grownPage(traceId);
+ if (path.includes("/spans?")) return firstPage(traceId);
+ throw new Error(`unmocked path: ${path}`);
+ });
+ vi.mocked(api.note).mockResolvedValue({});
+});
+
+describe("TracePanel 'Load more' denominator while a trace is filtered out", () => {
+ it("uses the live spans-endpoint total rather than the frozen span_count", async () => {
+ render();
+
+ // The single trace (span_count 250) is auto-selected; the first spans
+ // page returns total 250 with next_offset 200, so the badge is visible.
+ const loadMore = await screen.findByRole(
+ "button",
+ { name: /Load more spans/ },
+ { timeout: 5000 },
+ );
+ expect(loadMore).toHaveTextContent("Load more spans (200 of 250)");
+
+ // Type a non-matching search so the selected trace disappears from the
+ // polled recent-traces list; App falls back to the frozen `selection` and
+ // trace.span_count stops updating.
+ fireEvent.change(screen.getByLabelText("Search traces"), {
+ target: { value: "xyz" },
+ });
+ await waitFor(() =>
+ expect(api.read).toHaveBeenCalledWith("traces?limit=200&q=xyz"),
+ );
+ // Confirm the filter is committed (the trace list now shows the
+ // no-match message) and the selected trace is frozen out of the list.
+ await waitFor(() =>
+ expect(
+ screen.getByText("No traces match this search."),
+ ).toBeInTheDocument(),
+ );
+
+ // Click "Load more": the live spans endpoint now sees a grown trace
+ // (total 600) and returns next_offset 400, so the button stays visible.
+ await act(async () => {
+ fireEvent.click(loadMore);
+ await Promise.resolve();
+ });
+
+ const after = screen.getByRole("button", { name: /Load more spans/ });
+ // The badge must not show the impossible "loaded exceeds total" count
+ // against the stale denominator; it must reflect the live total (600).
+ expect(after).not.toHaveTextContent("400 of 250");
+ expect(after).toHaveTextContent("Load more spans (400 of 600)");
+ // The header `N spans` must agree with the badge's denominator (600
+ // spans), not the frozen span_count (250 spans).
+ expect(screen.getByText(/600 spans/)).toBeInTheDocument();
+ expect(screen.queryByText(/250 spans/)).not.toBeInTheDocument();
+ }, 15000);
+});
diff --git a/desktop/src/panels.tsx b/desktop/src/panels.tsx
index 28fb085..8c6cdfa 100644
--- a/desktop/src/panels.tsx
+++ b/desktop/src/panels.tsx
@@ -137,6 +137,7 @@ export function TracePanel({
const [next, setNext] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
+ const [liveTotal, setLiveTotal] = useState(null);
useEffect(() => {
setSpanId(requestedSpan);
setExtra([]);
@@ -151,6 +152,7 @@ export function TracePanel({
useEffect(() => {
setExtra([]);
setNext(page.data?.next_offset ?? null);
+ setLiveTotal(page.data?.total ?? null);
}, [page.data]);
const inspected = useLive(
spanId ? `${runId}:${trace.trace_id}:${spanId}` : null,
@@ -158,6 +160,7 @@ export function TracePanel({
0,
);
const spans = [...(page.data?.spans ?? []), ...extra];
+ const displayedTotal = Math.max(liveTotal ?? 0, trace.span_count);
const selection = useRef(`${runId}:${trace.trace_id}:${trace.span_count}`);
selection.current = `${runId}:${trace.trace_id}:${trace.span_count}`;
async function more() {
@@ -171,6 +174,7 @@ export function TracePanel({
if (selection.current === key) {
setExtra((items) => [...items, ...result.spans]);
setNext(result.next_offset);
+ setLiveTotal(result.total);
}
} catch (e) {
setError(String(e));
@@ -207,7 +211,7 @@ export function TracePanel({
{trace.name}
{trace.services.join(", ")} · {duration(trace.duration_ns)}{" "}
- · {trace.span_count} spans
+ · {displayedTotal} spans
@@ -343,7 +347,7 @@ export function TracePanel({
disabled={busy}
onClick={() => void more()}
>
- Load more spans ({spans.length} of {trace.span_count})
+ Load more spans ({spans.length} of {displayedTotal})
)}