fix(ui): bound WebSocket turn rendering and remove quadratic scans

This commit is contained in:
Brennan Butler 2026-09-20 09:01:43 +07:00
parent 74119b7c8f
commit 44b461fa25
4 changed files with 79 additions and 22 deletions

View file

@ -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(<PrettyMessagesView request={{}} response={response} />);
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: [] } },

View file

@ -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 && (
<div className="divide-y divide-border">
{turns.map((turn, index) => (
{turns.slice(start, end).map((turn, index) => (
<section
key={`${turn.id}-${index}`}
key={`${turn.id}-${start + index}`}
className="px-4 py-3"
aria-label={`Turn ${index + 1} · ${turn.status}`}
aria-label={`Turn ${start + index + 1} · ${turn.status}`}
>
<h4 className="mb-2 text-sm font-medium">
Turn {index + 1} · {turn.status}
Turn {start + index + 1} · {turn.status}
</h4>
{turn.id && <div className="mb-2 break-all text-xs text-muted-foreground">{turn.id}</div>}
{turn.detail && <p className="mb-2 whitespace-pre-wrap text-sm">{turn.detail}</p>}
@ -54,6 +62,24 @@ export function ResponsesWebSocketPrettyView({
)}
</section>
))}
{turns.length > TURNS_PER_PAGE && (
<nav aria-label="Response turns" className="flex items-center justify-between gap-2 px-4 py-3">
<Button variant="outline" size="sm" disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>
Previous turns
</Button>
<span className="text-sm" aria-live="polite">
Turns {start + 1}–{end} of {turns.length}
</span>
<Button
variant="outline"
size="sm"
disabled={currentPage === lastPage}
onClick={() => setPage(currentPage + 1)}
>
Next turns
</Button>
</nav>
)}
</div>
)}
</div>

View file

@ -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,
{},

View file

@ -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<string>();
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 [