diff --git a/ui/src/components/transcript/RunTranscriptView.test.tsx b/ui/src/components/transcript/RunTranscriptView.test.tsx index 41f635d2b3f..46f9f3d956f 100644 --- a/ui/src/components/transcript/RunTranscriptView.test.tsx +++ b/ui/src/components/transcript/RunTranscriptView.test.tsx @@ -110,4 +110,23 @@ describe("RunTranscriptView", () => { expect(html).toMatch(/]*>posted issue update<\/li>/); expect(html).not.toContain("result"); }); + + it("windows large raw transcripts instead of rendering every entry at once", () => { + const entries: TranscriptEntry[] = Array.from({ length: 500 }, (_, index) => ({ + kind: "stdout", + ts: `2026-03-12T00:${String(index % 60).padStart(2, "0")}:00.000Z`, + text: `line-${index}`, + })); + + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain("line-0"); + expect(html).toContain("line-179"); + expect(html).not.toContain("line-250"); + expect(html).not.toContain("line-499"); + }); }); diff --git a/ui/src/components/transcript/RunTranscriptView.tsx b/ui/src/components/transcript/RunTranscriptView.tsx index fee3a561e89..a9130f8cde8 100644 --- a/ui/src/components/transcript/RunTranscriptView.tsx +++ b/ui/src/components/transcript/RunTranscriptView.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { TranscriptEntry } from "../../adapters"; import { MarkdownBody } from "../MarkdownBody"; import { cn, formatTokens } from "../../lib/utils"; @@ -16,6 +16,11 @@ import { export type TranscriptMode = "nice" | "raw"; export type TranscriptDensity = "comfortable" | "compact"; +const RAW_VIRTUALIZATION_THRESHOLD = 300; +const RAW_OVERSCAN_ROWS = 40; +const RAW_ESTIMATED_ROW_HEIGHT = 36; +const RAW_INITIAL_ROWS = 180; + interface RunTranscriptViewProps { entries: TranscriptEntry[]; mode?: TranscriptMode; @@ -1347,6 +1352,34 @@ function TranscriptStdoutRow({ ); } +function findScrollParent(element: HTMLElement): HTMLElement | Window { + let current = element.parentElement; + while (current) { + const style = window.getComputedStyle(current); + if (/(auto|scroll)/.test(style.overflowY) && current.scrollHeight > current.clientHeight) { + return current; + } + current = current.parentElement; + } + return window; +} + +function rawEntryContent(entry: TranscriptEntry): string { + if (entry.kind === "tool_call") { + return `${entry.name}\n${formatToolPayload(entry.input)}`; + } + if (entry.kind === "tool_result") { + return formatToolPayload(entry.content); + } + if (entry.kind === "result") { + return `${entry.text}\n${formatTokens(entry.inputTokens)} / ${formatTokens(entry.outputTokens)} / $${entry.costUsd.toFixed(6)}`; + } + if (entry.kind === "init") { + return `model=${entry.model}${entry.sessionId ? ` session=${entry.sessionId}` : ""}`; + } + return entry.text; +} + function RawTranscriptView({ entries, density, @@ -1355,11 +1388,63 @@ function RawTranscriptView({ density: TranscriptDensity; }) { const compact = density === "compact"; + const listRef = useRef(null); + const shouldVirtualize = entries.length > RAW_VIRTUALIZATION_THRESHOLD; + const [range, setRange] = useState(() => ({ + start: 0, + end: Math.min(entries.length, shouldVirtualize ? RAW_INITIAL_ROWS : entries.length), + })); + + useEffect(() => { + if (!shouldVirtualize) { + setRange({ start: 0, end: entries.length }); + return; + } + + const list = listRef.current; + if (!list) return; + + const scrollParent = findScrollParent(list); + const updateRange = () => { + const scrollElement: HTMLElement | null = scrollParent === window ? null : (scrollParent as HTMLElement); + const scrollerTop = scrollElement ? scrollElement.getBoundingClientRect().top : 0; + const scrollerHeight = scrollElement ? scrollElement.clientHeight : window.innerHeight; + const listTop = list.getBoundingClientRect().top; + const visibleTop = Math.max(0, scrollerTop - listTop); + const visibleBottom = Math.max(visibleTop + scrollerHeight, 0); + const nextStart = Math.max(0, Math.floor(visibleTop / RAW_ESTIMATED_ROW_HEIGHT) - RAW_OVERSCAN_ROWS); + const nextEnd = Math.min( + entries.length, + Math.ceil(visibleBottom / RAW_ESTIMATED_ROW_HEIGHT) + RAW_OVERSCAN_ROWS, + ); + setRange((current) => ( + current.start === nextStart && current.end === nextEnd + ? current + : { start: nextStart, end: nextEnd } + )); + }; + + updateRange(); + const frame = window.requestAnimationFrame(updateRange); + scrollParent.addEventListener("scroll", updateRange, { passive: true }); + window.addEventListener("resize", updateRange); + return () => { + window.cancelAnimationFrame(frame); + scrollParent.removeEventListener("scroll", updateRange); + window.removeEventListener("resize", updateRange); + }; + }, [entries.length, shouldVirtualize]); + + const visibleEntries = shouldVirtualize ? entries.slice(range.start, range.end) : entries; + const topSpacer = shouldVirtualize ? range.start * RAW_ESTIMATED_ROW_HEIGHT : 0; + const bottomSpacer = shouldVirtualize ? Math.max(0, entries.length - range.end) * RAW_ESTIMATED_ROW_HEIGHT : 0; + return ( -
- {entries.map((entry, idx) => ( +
+ {topSpacer > 0 &&