fix(ui): show all session logs in the drawer, not just the first page

The session detail drawer fetched session logs via sessionSpendLogsCall
without page/page_size, so it only ever received the backend default of one
page (50 rows). Sessions with more than 50 calls had the rest unreachable in
the UI (#29153).

sessionSpendLogsCall now takes page/page_size, and the drawer fetches the
first page, reads total_pages, then fetches the remaining pages and
accumulates them before the existing client-side sort. This keeps the single
continuous list (and the selected-log lookup and keyboard navigation, which
all assume the full session) correct. Fetching is bounded by a page cap, and
the sidebar shows a "showing most recent N" note if a session exceeds it.

The rows are lightweight metadata (the endpoint excludes messages/response),
so the full set is small; request/response bodies are still loaded per log on
demand.
This commit is contained in:
Ceder 2026-06-05 17:10:50 +02:00
parent 0acf4812d9
commit bc37791e5b
3 changed files with 108 additions and 9 deletions

View file

@ -453,3 +453,50 @@ describe("teamInfoCall", () => {
expect(parsed.searchParams.has("team_id")).toBe(false);
});
});
describe("sessionSpendLogsCall", () => {
const originalFetch = global.fetch;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
global.fetch = originalFetch;
});
it("should request the first page with defaults so the caller can page through the session", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ data: [], total: 0, page: 1, page_size: 100, total_pages: 1 }),
} as any);
global.fetch = mockFetch as any;
await Networking.sessionSpendLogsCall("token", "session-123");
expect(mockFetch).toHaveBeenCalledOnce();
const [url] = mockFetch.mock.calls[0];
const urlStr = typeof url === "string" ? url : (url as Request).url;
const parsed = typeof url === "string" ? new URL(url, "http://example.com") : new URL((url as Request).url);
expect(urlStr).toContain("/spend/logs/session/ui");
expect(parsed.searchParams.get("session_id")).toBe("session-123");
expect(parsed.searchParams.get("page")).toBe("1");
expect(parsed.searchParams.get("page_size")).toBe("100");
});
it("should pass explicit page and page_size query params for later pages", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ data: [], total: 250, page: 3, page_size: 100, total_pages: 3 }),
} as any);
global.fetch = mockFetch as any;
await Networking.sessionSpendLogsCall("token", "session-123", 3, 100);
const [url] = mockFetch.mock.calls[0];
const parsed = typeof url === "string" ? new URL(url, "http://example.com") : new URL((url as Request).url);
expect(parsed.searchParams.get("page")).toBe("3");
expect(parsed.searchParams.get("page_size")).toBe("100");
});
});

View file

@ -7143,13 +7143,27 @@ export const teamPermissionsUpdateCall = async (accessToken: string, teamId: str
};
/**
* Get all spend logs for a particular session
* Get a page of spend logs for a particular session.
*
* The backend paginates this endpoint (page / page_size, returning
* { data, total, page, page_size, total_pages }). Callers that need the whole
* session should page through total_pages and accumulate the results.
*/
export const sessionSpendLogsCall = async (accessToken: string, session_id: string) => {
export const sessionSpendLogsCall = async (
accessToken: string,
session_id: string,
page: number = 1,
page_size: number = 100,
) => {
try {
const params = new URLSearchParams({
session_id,
page: String(page),
page_size: String(page_size),
});
let url = proxyBaseUrl
? `${proxyBaseUrl}/spend/logs/session/ui?session_id=${encodeURIComponent(session_id)}`
: `/spend/logs/session/ui?session_id=${encodeURIComponent(session_id)}`;
? `${proxyBaseUrl}/spend/logs/session/ui?${params.toString()}`
: `/spend/logs/session/ui?${params.toString()}`;
const response = await fetch(url, {
method: "GET",

View file

@ -28,6 +28,14 @@ export interface LogDetailsDrawerProps {
const SIDEBAR_WIDTH_PX = 224;
// Session logs are fetched page-by-page from the paginated backend and
// accumulated so the drawer can show the whole session. page_size is the
// backend maximum (le=100); the page cap bounds the fetch and the
// (un-virtualized) sidebar list for pathological sessions, keeping the most
// recent logs since the endpoint returns newest-first.
const SESSION_PAGE_SIZE = 100;
const MAX_SESSION_PAGES = 50;
/* ------------------------------------------------------------------ */
/* TraceEventRow — compact event row used in both session & non- */
/* session sidebar lists. Extracted to avoid JSX duplication. */
@ -112,13 +120,30 @@ export function LogDetailsDrawer({
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [copiedLeftPanelId, setCopiedLeftPanelId] = useState(false);
const { data: sessionLogs = [] } = useQuery({
const { data: sessionData } = useQuery({
queryKey: ["sessionLogs", sessionId],
queryFn: async () => {
if (!sessionId || !accessToken) return [];
const response = await sessionSpendLogsCall(accessToken, sessionId);
const allSessionLogs: LogEntry[] = response.data || response || [];
return allSessionLogs
if (!sessionId || !accessToken) return { logs: [] as LogEntry[], total: 0 };
// Fetch the first page, then page through the rest so sessions with more
// than one page of logs are shown in full (capped for safety).
const firstPage = await sessionSpendLogsCall(accessToken, sessionId, 1, SESSION_PAGE_SIZE);
let rows: LogEntry[] = firstPage.data || firstPage || [];
const total: number = firstPage.total ?? rows.length;
const pagesToFetch = Math.min(firstPage.total_pages ?? 1, MAX_SESSION_PAGES);
if (pagesToFetch > 1) {
const remaining = await Promise.all(
Array.from({ length: pagesToFetch - 1 }, (_, i) =>
sessionSpendLogsCall(accessToken, sessionId, i + 2, SESSION_PAGE_SIZE),
),
);
for (const page of remaining) {
rows = rows.concat(page.data || []);
}
}
const logs = rows
.map((row) => ({
...row,
request_duration_ms: row.request_duration_ms ?? Date.parse(row.endTime) - Date.parse(row.startTime),
@ -129,10 +154,18 @@ export function LogDetailsDrawer({
if (aIsMcp !== bIsMcp) return aIsMcp - bIsMcp;
return new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
});
return { logs, total };
},
enabled: Boolean(open && isSessionMode && sessionId && accessToken),
});
const sessionLogs: LogEntry[] = sessionData?.logs ?? [];
// total reported by the backend; when the page cap truncates the fetch this
// exceeds sessionLogs.length, which drives the "showing most recent" note.
const sessionTotalCount = sessionData?.total ?? sessionLogs.length;
const sessionTruncated = sessionTotalCount > sessionLogs.length;
const currentLog = useMemo(() => {
if (!isSessionMode) return logEntry;
if (!sessionLogs.length) return null;
@ -327,6 +360,11 @@ export function LogDetailsDrawer({
</>
)}
</div>
{isSessionMode && sessionTruncated && (
<div className="mt-1 text-[11px] text-amber-600 font-mono">
Showing most recent {logsForList.length} of {sessionTotalCount}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto">