From f49a3f99243a11b154b22b88c2f0ede16bf66b48 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:43:05 -0500 Subject: [PATCH] fix(ui): cap live agent-run transcript buffers to bound tab memory (#9569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents stream their run output live into the web UI, viewed per-run in the `AgentDetail` transcript viewer > - Browser tabs holding these views were sitting at 8–16 GB of memory footprint while their JS heap stayed at ~256 MB — a 60×+ gap, meaning the cost is in retained DOM / render objects, not JS objects > - The `LogViewer` in `AgentDetail.tsx` kept every streamed stdout/stderr line and structured event in unbounded React state and rendered each into a rich DOM block (the default "nice" mode has no virtualization), so a run streaming for hours grew an unbounded live DOM tree > - The sibling `useLiveRunTranscripts` hook (used by `IssueChatThread`) already bounds its buffers and virtualizes; `LogViewer` bypassed it and managed its own uncapped state — that inconsistency is the bug > - This pull request caps the live buffers and bounds the live DOM render, aligning `LogViewer` with the already-bounded path > - The benefit is that long-lived streaming tabs no longer grow without bound, cutting multi-GB tabs back to a bounded footprint ## Linked Issues or Issue Description No public GitHub issue exists; describing inline per CONTRIBUTING.md → "Link Issues or Describe Them In-PR", following the bug report template. **What happened?** Chrome's Task Manager showed multiple long-lived Paperclip tabs (agent-run / task views) each consuming 8–16 GB of memory footprint, while each tab's JS heap stayed at only ~150–256 MB. Memory grew monotonically the longer a run streamed. **Expected behavior** A tab viewing a live agent run should hold a bounded amount of memory regardless of how long the run streams. **Steps to reproduce** Open an agent run with a long-running / high-volume stream in `AgentDetail`, leave the tab open while output streams for an extended period, and watch the tab's memory footprint climb without bound in Chrome's Task Manager. **Paperclip version or commit** `3991a19a` (branch `fix/agent-run-transcript-memory`, off `master`). **Deployment mode** Local dev (`pnpm dev`), web UI. Not adapter-specific — core UI bug in the shared transcript viewer. ## What Changed - Add `ui/src/lib/live-log-buffer.ts`: a pure `appendCapped(prev, additions, max)` helper plus caps `MAX_LIVE_LOG_LINES=5000`, `MAX_LIVE_EVENTS=2000`, and `LIVE_TRANSCRIPT_RENDER_LIMIT=1500`, with rationale documented in the module. - Add `ui/src/lib/live-log-buffer.test.ts`: 6 unit tests (append, trim-to-cap, oversized batch, exact-cap, no-mutation, referential bail-out). - `ui/src/pages/AgentDetail.tsx` (`LogViewer`): route all four live-append sites (WebSocket log / progress / event, plus the poll fallback) through `appendCapped`, and pass `limit={LIVE_TRANSCRIPT_RENDER_LIMIT}` to `RunTranscriptView` for live runs so the "nice" view mounts only the most recent blocks. - The terminated-run "Load more log" pagination is deliberately left **uncapped** (guarded by `isLive`), so no historical output is lost — older output remains on the server and reachable there. ## Verification - `vitest run src/lib/live-log-buffer.test.ts` → 6/6 pass. - Existing suites `RunTranscriptView.test.tsx`, `AgentDetail.instructions.test.tsx`, `useLiveRunTranscripts.test.tsx` → 22/22 pass. - `tsc -b` (UI) → clean. - Manual/behavioral: live runs tail the last ~1500 blocks; terminated runs still render full history via "Load more log". Follow-up planned to profile before/after with the Chrome DevTools MCP. ## Risks Low risk. Changes only bound **in-memory state for live runs**; the terminated-run paginated path is untouched (still uncapped, guarded by `isLive`). No API, schema, or persistence changes. Worst case for a live run is that only the most recent 5000 lines / 1500 rendered blocks are visible in the tab — which is the intended "tail" behavior, and full history remains on the server. ## Model Used - **Provider:** Anthropic, via the Claude Code CLI. - **Model:** Claude Opus 4.8 (`claude-opus-4-8`). - **Reasoning mode:** Extended thinking enabled. - **Capabilities used:** tool use (shell execution, file editing), sub-agent fan-out for the codebase memory sweep, and the Chrome DevTools MCP for the diagnosis phase. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work (the ROADMAP "Memory" item is about company/agent knowledge, unrelated to this browser-tab memory fix) - [x] I have searched GitHub for duplicate or related PRs and linked them above (none found among open PRs) - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have considered and documented any risks above - [ ] I have updated relevant documentation to reflect my changes (N/A — no user-facing docs affected; rationale is documented inline in `live-log-buffer.ts`) - [ ] All Paperclip CI gates are green (in progress at time of writing) - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (the only open P2 was this missing template, which this update resolves; awaiting re-review) - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 --- ui/src/lib/live-log-buffer.test.ts | 35 ++++++++++++++++++++++++++++++ ui/src/lib/live-log-buffer.ts | 34 +++++++++++++++++++++++++++++ ui/src/pages/AgentDetail.tsx | 21 +++++++++++++----- 3 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 ui/src/lib/live-log-buffer.test.ts create mode 100644 ui/src/lib/live-log-buffer.ts diff --git a/ui/src/lib/live-log-buffer.test.ts b/ui/src/lib/live-log-buffer.test.ts new file mode 100644 index 0000000000..bf18c5440f --- /dev/null +++ b/ui/src/lib/live-log-buffer.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { appendCapped } from "./live-log-buffer"; + +describe("appendCapped", () => { + it("returns the same array reference when there is nothing to add", () => { + const prev = [1, 2, 3]; + expect(appendCapped(prev, [], 10)).toBe(prev); + }); + + it("appends without trimming while under the cap", () => { + expect(appendCapped([1, 2], [3, 4], 10)).toEqual([1, 2, 3, 4]); + }); + + it("keeps exactly the newest `max` entries when the result overflows", () => { + expect(appendCapped([1, 2, 3], [4, 5], 4)).toEqual([2, 3, 4, 5]); + }); + + it("trims correctly when a single append batch is larger than the cap", () => { + expect(appendCapped([1], [2, 3, 4, 5, 6], 3)).toEqual([4, 5, 6]); + }); + + it("returns exactly `max` entries when the result lands on the cap", () => { + const result = appendCapped([1, 2], [3], 3); + expect(result).toEqual([1, 2, 3]); + expect(result.length).toBe(3); + }); + + it("does not mutate its inputs", () => { + const prev = [1, 2, 3]; + const additions = [4, 5]; + appendCapped(prev, additions, 3); + expect(prev).toEqual([1, 2, 3]); + expect(additions).toEqual([4, 5]); + }); +}); diff --git a/ui/src/lib/live-log-buffer.ts b/ui/src/lib/live-log-buffer.ts new file mode 100644 index 0000000000..f1d83539d5 --- /dev/null +++ b/ui/src/lib/live-log-buffer.ts @@ -0,0 +1,34 @@ +/** + * Live agent-run transcript viewers stream stdout/stderr and structured events + * for the entire lifetime of a run — which can be hours. The viewer keeps every + * streamed line/event in React state and renders each into a rich DOM block, so + * an unbounded buffer becomes an unbounded live DOM tree: the tab's memory + * footprint climbs into the multi-GB range even while the JS heap stays small + * (the cost is in retained render objects and GPU layers, not JS objects). + * + * These caps bound the *live* tail retained in memory. Older output is not lost: + * it stays in the persisted run log on the server and remains reachable for + * terminated runs through the "Load more log" pagination, which is not subject + * to these caps. + */ +export const MAX_LIVE_LOG_LINES = 5_000; +export const MAX_LIVE_EVENTS = 2_000; + +/** + * Number of transcript blocks the live "nice" view mounts. Terminated runs + * render in full so explicitly paginated history stays visible; live runs tail + * the stream, so mounting only the most recent blocks keeps the DOM (and its + * off-heap render memory) bounded no matter how long the run streams. + */ +export const LIVE_TRANSCRIPT_RENDER_LIMIT = 1_500; + +/** + * Append `additions` to `prev`, dropping the oldest entries so the result never + * exceeds `max`. Returns `prev` unchanged when there is nothing to add, so React + * state setters can bail out of a re-render, and never mutates its inputs. + */ +export function appendCapped(prev: T[], additions: readonly T[], max: number): T[] { + if (additions.length === 0) return prev; + const next = [...prev, ...additions]; + return next.length > max ? next.slice(next.length - max) : next; +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index ab60cae756..b0564d73ca 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -87,6 +87,12 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { Input } from "@/components/ui/input"; import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker"; import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView"; +import { + appendCapped, + LIVE_TRANSCRIPT_RENDER_LIMIT, + MAX_LIVE_EVENTS, + MAX_LIVE_LOG_LINES, +} from "../lib/live-log-buffer"; import { isUuidLike, type Agent, @@ -3492,7 +3498,11 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin } if (parsed.length > 0) { - setLogLines((prev) => [...prev, ...parsed]); + // Live runs stream forever, so cap the retained tail. Terminated runs are + // paginated by the user via "Load more log" and keep their full history. + setLogLines((prev) => + isLive ? appendCapped(prev, parsed, MAX_LIVE_LOG_LINES) : [...prev, ...parsed], + ); } } @@ -3661,7 +3671,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin try { const newEvents = await heartbeatsApi.events(run.id, maxSeq, 100); if (newEvents.length > 0) { - setEvents((prev) => [...prev, ...newEvents]); + setEvents((prev) => appendCapped(prev, newEvents, MAX_LIVE_EVENTS)); } } catch { // ignore polling errors @@ -3738,7 +3748,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin const streamRaw = asNonEmptyString(payload.stream); const stream = streamRaw === "stderr" || streamRaw === "system" ? streamRaw : "stdout"; const ts = asNonEmptyString((payload as Record).ts) ?? event.createdAt; - setLogLines((prev) => [...prev, { ts, stream, chunk }]); + setLogLines((prev) => appendCapped(prev, [{ ts, stream, chunk }], MAX_LIVE_LOG_LINES)); return; } @@ -3748,7 +3758,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin const key = heartbeatProgressLogLineKey(line); if (seenProgressLogLineKeysRef.current.has(key)) return; seenProgressLogLineKeysRef.current.add(key); - setLogLines((prev) => [...prev, line]); + setLogLines((prev) => appendCapped(prev, [line], MAX_LIVE_LOG_LINES)); return; } @@ -3785,7 +3795,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin setEvents((prev) => { if (prev.some((existing) => existing.seq === seq)) return prev; - return [...prev, liveEvent]; + return appendCapped(prev, [liveEvent], MAX_LIVE_EVENTS); }); }; @@ -3928,6 +3938,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin entries={transcript} mode={transcriptMode} streaming={isLive} + limit={isLive ? LIVE_TRANSCRIPT_RENDER_LIMIT : undefined} emptyMessage={run.logRef ? "Waiting for transcript..." : "No persisted transcript for this run."} /> {hasMoreLog && (