From 44b461fa2577fdccd3a20c8b10eace4ff2544101 Mon Sep 17 00:00:00 2001 From: Brennan Butler <64561607+brennanbutler01@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:01:43 +0700 Subject: [PATCH] fix(ui): bound WebSocket turn rendering and remove quadratic scans --- .../PrettyMessagesView.integration.test.tsx | 23 ++++++++++++- .../ResponsesWebSocketPrettyView.tsx | 34 ++++++++++++++++--- .../prettyMessagesUtils.test.ts | 17 ++++++++++ .../LogDetailsDrawer/prettyMessagesUtils.ts | 27 ++++++--------- 4 files changed, 79 insertions(+), 22 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/PrettyMessagesView.integration.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/PrettyMessagesView.integration.test.tsx index efd1a2ca995..7282aa4d666 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/PrettyMessagesView.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/PrettyMessagesView.integration.test.tsx @@ -1,9 +1,30 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, it, expect } from "vitest"; import { PrettyMessagesView } from "./PrettyMessagesView"; describe("PrettyMessagesView", () => { + it("bounds rendered WebSocket turns and lets operators navigate all pages", () => { + const response = Array.from({ length: 101 }, (_, index) => ({ + type: "response.completed", + response: { id: `resp_${index}`, output: [] }, + })); + render(); + expect(screen.getAllByRole("region", { name: /^Turn / })).toHaveLength(50); + expect(screen.getByText("Turns 1–50 of 101")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Previous turns" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Next turns" })); + expect(screen.queryByText("Turn 1 · Completed")).not.toBeInTheDocument(); + expect(screen.getByText("Turn 51 · Completed")).toBeInTheDocument(); + expect(screen.getAllByRole("region", { name: /^Turn / })).toHaveLength(50); + fireEvent.click(screen.getByRole("button", { name: "Next turns" })); + expect(screen.getByText("Turns 101–101 of 101")).toBeInTheDocument(); + expect(screen.getAllByRole("region", { name: /^Turn / })).toHaveLength(1); + expect(screen.getByRole("button", { name: "Next turns" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Previous turns" })); + expect(screen.getByText("Turn 51 · Completed")).toBeInTheDocument(); + }); + it.each([false, true])("renders separate WebSocket turns (wrapped: %s)", (wrapped) => { const events = [ { type: "response.created", response: { id: "resp_1", output: [] } }, diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ResponsesWebSocketPrettyView.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ResponsesWebSocketPrettyView.tsx index 3569697dd4b..4035e6204f6 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ResponsesWebSocketPrettyView.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ResponsesWebSocketPrettyView.tsx @@ -3,6 +3,9 @@ import { toast } from "@/lib/toast"; import { ResponsesWebSocketTurn } from "./prettyMessagesTypes"; import { SectionHeader } from "./SectionHeader"; import { SimpleMessageBlock } from "./SimpleMessageBlock"; +import { Button } from "@/components/ui/button"; + +const TURNS_PER_PAGE = 50; interface ResponsesWebSocketPrettyViewProps { turns: readonly ResponsesWebSocketTurn[]; @@ -16,6 +19,11 @@ export function ResponsesWebSocketPrettyView({ outputCost, }: ResponsesWebSocketPrettyViewProps) { const [isCollapsed, setIsCollapsed] = useState(false); + const [page, setPage] = useState(0); + const lastPage = Math.max(0, Math.ceil(turns.length / TURNS_PER_PAGE) - 1); + const currentPage = Math.min(page, lastPage); + const start = currentPage * TURNS_PER_PAGE; + const end = Math.min(start + TURNS_PER_PAGE, turns.length); const copyOutput = async () => { await navigator.clipboard.writeText(JSON.stringify(turns, null, 2)); toast.success("Output copied"); @@ -32,14 +40,14 @@ export function ResponsesWebSocketPrettyView({ /> {!isCollapsed && (
- {turns.map((turn, index) => ( + {turns.slice(start, end).map((turn, index) => (

- Turn {index + 1} · {turn.status} + Turn {start + index + 1} · {turn.status}

{turn.id &&
{turn.id}
} {turn.detail &&

{turn.detail}

} @@ -54,6 +62,24 @@ export function ResponsesWebSocketPrettyView({ )}
))} + {turns.length > TURNS_PER_PAGE && ( + + )}
)} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.test.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.test.ts index 15e44916038..e7881a6f5c8 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.test.ts +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.test.ts @@ -2,6 +2,23 @@ import { describe, expect, it } from "vitest"; import { parseResponsesWebSocketTurns } from "./prettyMessagesUtils"; describe("parseResponsesWebSocketTurns", () => { + it("reads event types a bounded number of times for a large session", () => { + const reads = { count: 0 }; + const events = Array.from({ length: 2000 }, (_, index) => + ["response.created", "response.completed", "response.completed"].map((type) => ({ + get type() { + reads.count += 1; + return type; + }, + response: { id: `resp_${index}`, output: [] }, + })), + ).flat(); + const turns = parseResponsesWebSocketTurns(events); + expect(turns).toHaveLength(2000); + expect(turns?.[1999].id).toBe("resp_1999"); + expect(reads.count).toBeLessThan(events.length * 20); + }); + it.each([ null, {}, diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.ts index 656698a762e..a01382516c2 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.ts +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.ts @@ -71,16 +71,17 @@ export const parseResponsesWebSocketTurns = (response: unknown): ResponsesWebSoc "response.incomplete": "Incomplete", "response.failed": "Failed", }; - return events.flatMap((event, index): ResponsesWebSocketTurn[] => { + const terminalIds = new Set( + events.flatMap((event) => + terminalTypes.includes(asString(event.type)) && isRecord(event.response) ? [asString(event.response.id)] : [], + ), + ); + const seenTerminalIds = new Set(); + return events.flatMap((event): ResponsesWebSocketTurn[] => { const body = isRecord(event.response) ? event.response : {}; const id = asString(body.id); if (event.type === "response.created") { - const hasTerminal = events.some( - (other) => - terminalTypes.includes(asString(other.type)) && - isRecord(other.response) && - asString(other.response.id) === id, - ); + const hasTerminal = terminalIds.has(id); return hasTerminal ? [] : [{ id, status: "No terminal event recorded", message: null, detail: "" }]; } if (event.type === "error") { @@ -88,16 +89,8 @@ export const parseResponsesWebSocketTurns = (response: unknown): ResponsesWebSoc return [{ id: "", status: "Error", message: null, detail: asString(error.message) || asString(error.code) }]; } if (!terminalTypes.includes(asString(event.type))) return []; - if ( - id && - events - .slice(0, index) - .some( - (other) => - terminalTypes.includes(asString(other.type)) && isRecord(other.response) && other.response.id === id, - ) - ) - return []; + if (id && seenTerminalIds.has(id)) return []; + if (id) seenTerminalIds.add(id); const error = isRecord(body.error) ? body.error : {}; const incomplete = isRecord(body.incomplete_details) ? body.incomplete_details : {}; return [