Files
nucleic/docs/TRANSCRIPT_LOADING_RENDERING_PERFORMANCE_PLAN.md
T

25 KiB
Raw Blame History

Transcript loading and rendering performance plan

Investigated 2026-08-01. Companion to MAIN_THREAD_PERFORMANCE_PLAN.md and TRANSCRIPT_INCREMENTAL_PROJECTION.md.

Status: in progress (P0 measurement seam started; P1 render index, P2 demand-driven controller reconstruction, P3 card presentation models, P4 cost-aware virtualization, and P5 settled-history snapshots implemented). Existing incremental projection, Markdown caching, tail-first preview, and native-list virtualization remain valuable foundations. This plan addresses the remaining cost of decoding large non-text payloads and constructing complex transcript cards.

TL;DR

Tool cards are not the dominant projection cost. The dominant loading cost is that their complete inputs and results — along with raw/native events hidden by default — are still JSON-decoded and retained before the UI can decide it only needs a collapsed card. In measured tool-heavy transcripts, full decoding took 0.251.2 seconds while projection took 0.38 milliseconds.

There is a separate render-side risk. Generic tool cards correctly avoid materializing output while collapsed, but several specialized cards parse or scan results eagerly, grouped/nested cards can represent hundreds of children while counting as one projected row, and SwiftUI equality can compare recursive payloads. Historical traces recorded repeated two-row mount warnings in the 60160 ms range, but the current renderer lacks reliable per-card attribution. The first deliverable is therefore an A/B measurement seam; the largest architectural win is a lightweight render index with lazy payload hydration.

1. Current pipeline

Persistence and decoding

  • TranscriptReader.read() streams the JSONL file in 256 KiB chunks, but decodes every line into a complete AgentEvent and retains the complete [AgentEvent] array. This avoids extra whole-file copies but not the recursive JSONValue allocation for large inputs, results, or native payloads.
  • readReverseBatch() maps the file and scans backward for startup preview. Its 128 KiB byte limit is intentionally soft because a canonical event cannot be split, so a multi-megabyte event can occupy the first page whole.
  • AppStore.loadSessions publishes every eligible local session from database metadata, but only reconstructs controllers needed for eager recovery. Settled, ordinary awaiting-input, and archived sessions decode canonical history on first controller-only demand. A selected chat retains a foreground lane while its render-index preview races controller reconstruction.
  • The selected chat can publish up to four reverse pages before authoritative controller reconstruction completes. The complete decoded history later replaces that preview atomically.

Projection

  • IncrementalTranscriptProjection runs inside TranscriptProjectionWorker, away from the main actor. It seals stable history and refolds the live tail.
  • Debug/raw rows are filtered after projection. Their payloads have already been decoded and their events visited by the projector.
  • Preview pages prepend history, then authoritative replacement installs the full history. These shapes cannot use the ordinary append-only fast path and cause full-fold fallbacks.
  • Filtering, auth cleanup, segment identities/estimates, and metadata collection remain O(rows) or O(events) work around the incremental fold.

Rendering

  • Histories up to 96 projected rows use an eager stack. Longer histories use an AppKit-backed List with stable two-row batches.
  • Assistant Markdown is prewarmed off-main. For first paint, a virtualized transcript warms only the newest top-level segment; nested descendants follow in the background.
  • Generic ToolCallRow uses an O(1) resultHasText check while collapsed, materializes result text only when expanded, and caps rendered output at 100,000 characters.
  • Specialized cards do not all follow the same discipline. Host, git, answered-question, subagent, and delegated-plan paths can scan, join, encode, or parse result content during normal body evaluation. Git output is displayed in its collapsed card without the generic output cap.
  • Tool-group and orchestration cards may traverse their complete child arrays to derive headers or status. One group still counts as one projected row for the 96-row virtualization decision.
  • TranscriptItem.Equatable compares tool calls, results, groups, and nested children recursively. That makes a settled row's equality cost payload-shaped rather than revision-shaped.
  • First-mount Markdown collection includes assistant rows, but not always-visible Markdown embedded in plan or git cards. Inline tool-summary Markdown has an in-memory cache but no prewarm pass.

2. Measurements

Measurements used a release-mode Swift probe over actual persisted JSONL files with a warm filesystem cache. Times isolate reverse-page decoding, complete decoding, and the incremental UI projector's initial fold; they do not include SwiftUI layout.

Transcript profile Encoded size / events Tail decode Full decode Initial UI fold
99% tool-result payload 36.2 MiB / 980 162 ms 988 ms 1.2 ms
Tool-result dominated 22.5 MiB / 1,644 26 ms 561 ms 1.5 ms
One 4.48 MiB result 13.8 MiB / 3,477 26 ms 1,191 ms 4.1 ms
70% hidden raw/native 52.3 MiB / 34,648 51 ms 1,593 ms 46.8 ms
Assistant-delta dominated 56.7 MiB / 206,190 72 ms 2,273 ms 220 ms

Corpus shape at investigation time:

  • 1,193 transcript files;
  • 1,132,950,936 encoded bytes (about 1.05 GiB);
  • 13 transcripts larger than 10 MiB;
  • the largest 15 transcripts contain 30.3% of all encoded bytes;
  • eight of the largest 15 are tool-result dominated, with several others dominated by hidden raw/native payloads.

The important attribution is stable even though absolute timings vary by machine:

  1. Tool-result bytes strongly affect decoding and memory, but tool-card projection is usually negligible.
  2. Hidden raw/native bytes are not free; filtering them after projection is too late to improve load time.
  3. Event count is an independent dimension. A transcript with hundreds of thousands of text deltas has materially higher decode and fold cost even without tool cards.
  4. A soft tail-page byte budget does not provide a latency bound when the newest relevant event is itself large.

3. Goals and non-goals

Goals

  • Make first visible text independent of the aggregate size of collapsed tool results and hidden raw/native payloads.
  • Make application launch independent of the aggregate byte size of terminal sessions that are not opened.
  • Keep collapsed-row construction and equality O(1) in payload size and bounded in child count.
  • Preserve smooth scrolling and exact-enough height reservation for mixed Markdown/tool histories.
  • Retain lossless canonical JSONL, replay, resume, transfer, and debug-detail behavior.
  • Establish card-kind and payload-shape attribution so future regressions are measurable.

Non-goals

  • Removing tool cards or degrading their user-facing information.
  • Truncating or deleting canonical transcript history.
  • Replacing native List with direct LazyVStack; the latter already regressed scroll geometry.
  • Moving canonical event ordering or controller semantics into the UI cache.

4. Plan

P0 — Establish an attributable benchmark

Implementation status (2026-08-01): the actual transcript row hierarchy now has an explicit A/B selector for production, skeletons, generic, and text-only. Set NUCLEIC_TRANSCRIPT_RENDER_VARIANT or pass --transcript-render-variant to enable it. All variants consume the same decoded events and projection. Production, skeleton, and generic variants also share row identities and segmentation; text-only derives its smaller presentation list after projection. Benchmark launches log deterministic projected-shape attribution at projection and first paint: top-level rows, recursive child count/depth, tool calls, maximum group size, always-visible Markdown bytes, and card-kind counts. Ordinary launches remain on production rendering and skip the recursive statistics pass. Still required in P0: decode/line-byte phase metrics, local-readiness/state-swap/ geometry attribution, frame-stall capture, deterministic fixture generation, and the repeatable before/after report runner.

Add a deterministic transcript-render benchmark mode with four variants over the same decoded and projected input:

  1. full production cards;
  2. fixed-height card skeletons with the same row identities;
  3. generic cards only, bypassing specialized renderers;
  4. assistant/user text only.

Record these phases separately:

  • reverse scan and mapping;
  • event JSON decode, including decoded bytes and largest line;
  • initial/full versus incremental projection;
  • filter/auth cleanup, metadata, segmentation, and estimate calculation;
  • card-presentation-model preparation by card kind;
  • local row readiness → state swap → first geometry;
  • selection → first visible bottom-pinned text;
  • main-thread slices and frame stalls while opening, scrolling, and expanding.

Every sample should record event counts, encoded bytes by event kind, largest event, top-level row count, nested child count, maximum group size, visible Markdown bytes, cache hits, and render weight. Do not time a mount from an earlier speculative request through a later off-screen geometry callback; that lifetime produced invalid multi-second outliers in the prior instrumentation.

Build a fixed fixture corpus covering:

  • equal byte sizes as assistant text, string tool result, structured tool result, and hidden raw event;
  • equal event counts with small versus large payloads;
  • one oversized final event crossing the tail budget;
  • a short transcript containing one very large tool group;
  • a large Task wave with nested activity;
  • plan, commit, Q&A, host-exec, and Markdown-like tool outputs;
  • a delta-heavy completed response.

This phase gates optimization claims: no change is considered a render win unless full cards improve relative to skeletons on the same input.

P1 — Add a rebuildable transcript render index

Implementation status (2026-08-01): schema v1 is implemented as transcript.render-index.jsonl. Records contain bounded display metadata, lifecycle/payload shape, presentation revision/checkpoints, and exact canonical byte ranges. New writers maintain it across ordinary and verbatim appends; revert rewrites rebuild it under the new epoch; header rewrites invalidate it. Startup validates schema, identity, revert epoch, canonical size, and database last sequence without decoding a canonical event. The selected-chat preview pages the index backward, hydrates prose and small structural events by range, and projects bounded tool-result/raw skeletons. Missing, stale, or corrupt indexes retain the canonical reverse-page fallback and rebuild from the already-decoded event array on the bounded utility/background preparation lane. Controller reconstruction still requests complete canonical history; making that demand-driven is P2.

Maintain a schema-versioned sidecar alongside each canonical transcript. It is derived data and may be deleted or rebuilt without affecting replay or resume.

Each index record should contain only the data needed to construct a collapsed display skeleton:

  • sequence, timestamp, kind, message/tool/parent IDs, and lifecycle flags;
  • canonical JSONL byte offset and byte length;
  • tool name, bounded display label/detail, result-present flag, and error flag;
  • bounded plain-text excerpt where the collapsed UI requires one;
  • payload-shape metadata and a presentation revision;
  • optional settled-message or projected-row checkpoint metadata.

The canonical JSONL remains unchanged. Large input/result/native bodies stay addressable by offset and length and are decoded only when a consumer explicitly requests them. The writer updates the index as events append; startup validates it against schema version, transcript identity, file size, last sequence, and revert epoch. A stale or absent index falls back safely and rebuilds at background QoS.

The selected-chat path becomes:

  1. load the newest index records;
  2. project lightweight skeletons and show the visible tail;
  3. hydrate only visible assistant/user text and card fields required by their collapsed renderers;
  4. hydrate a full tool result only when disclosure, copy, export, debug detail, or controller semantics require it.

For future transcripts, optionally place sufficiently large bodies in content-addressed blob files and store a canonical reference event. That is a later format decision; the render index delivers the load win without changing canonical JSONL or wire compatibility.

P2 — Make controller reconstruction demand-driven

Implementation status (2026-08-01): launch now reconstructs only sessions identified from lightweight database facts as in-flight/interrupted, carrying unresolved approvals, potentially lock-bearing, or needed by transfer/orphan reconciliation. Terminal, ordinary awaiting-input, and archived sessions remain summary-only until open, resume, export, remote subscription, mutation, or another canonical controller operation requests them. On-demand hydration is single-flight and shares/promotes the startup preparation pool when applicable; unresolved approvals are restored into reconstructed controllers. Sidebar and wire summaries, environment reconciliation, and archived-worktree cleanup now use persisted rows/approval facts rather than controllers.keys. Focused launch tests cover summary-only relaunch, open-time hydration, transcript-derived metadata backfill, and eager recovery for interrupted and approval-bearing sessions.

The sidebar and dashboard already have database summaries and activity caches. Use those rather than fully decoding every terminal controller at launch.

Classify persisted sessions into:

  • eager recovery: genuinely interrupted/in-flight sessions, pending approvals, lock-bearing sessions, and any session needed by startup reconciliation;
  • on-demand: terminal, ordinary awaiting-input, and archived sessions with no recovery obligation.

Only eager-recovery sessions reconstruct controllers at launch. An on-demand controller is created when the chat is opened, resumed, exported through a controller-only path, or otherwise needs canonical semantics. Environment/lock reconciliation should consume lightweight database/index facts where possible instead of using controllers.keys as a proxy for every persisted session.

Keep the selected-session foreground lane, but let it race only necessary work. Background controller hydration must not consume CPU merely to make an unopened terminal transcript immediately resumable.

P3 — Introduce off-main card presentation models

Implementation status (2026-08-01): TranscriptProjectionWorker now builds and memoizes immutable, bounded ToolCardPresentation and ToolGroupPresentation snapshots by canonical input/result sequence revision. Generic, host, git, answered-question, plan, subagent, delegated-plan, and group cards consume prepared collapsed fields instead of parsing recursive JSON in body. Result payloads remain lazy references; disclosure materializes and caps their display body on a detached task, and git output is now explicitly disclosed rather than laid out in the collapsed card. Visible plan/git Markdown joins the segment prewarm pass. SwiftUI row/segment equality uses stable text identity plus the presentation revision while lossless TranscriptItem.Equatable remains available to projection tests and canonical consumers. Focused tests cover specialization, result-revision invalidation, bounded group headlines, the 100,000-character output cap, and separation of view equality from payload equality.

Projection should return immutable TranscriptRenderNode/ToolCardPresentation values whose fields are already suitable for a collapsed view. Build and memoize them off-main by tool-call ID plus input and result revision.

Precompute or cache:

  • card kind, icon, label, bounded detail, and render weight;
  • hasOutput, error/cancellation state, and a lazy payload reference;
  • host-command expansion and HostCommandSummary;
  • git block/commit summary;
  • parsed questions and answers;
  • subagent worker envelope/state;
  • delegated-plan batches, denial, and worker IDs;
  • tool-group family/headline lines and active lock state;
  • inline attributed summaries and always-visible Markdown sources.

Specialized cards must adopt the generic card's lazy-output contract:

  • collapsed rendering never joins, canonical-encodes, trims, or JSON-parses a full result;
  • expansion requests a bounded display body asynchronously;
  • rendering uses the existing 100,000-character cap before creating Text or MarkdownText;
  • copying/exporting can still request the complete payload;
  • git output is capped or disclosed rather than eagerly laid out in the collapsed card.

Replace TranscriptItem's payload-recursive view equality with stable identity plus a lightweight presentation revision/result sequence. Payload equality remains available to correctness tests and canonical logic, but SwiftUI diffing must not walk multi-megabyte JSON or nested child arrays.

P4 — Make virtualization cost-aware

Implementation status (2026-08-01): projection now publishes a deterministic render-cost snapshot covering top-level rows, visible Markdown bytes/block structure, group calls, orchestration workers, nested count/depth, specialized-card weight, and presentation/Markdown cache state. Settled row costs are memoized by the P3 presentation revision, so a live-tail update only remeasures changed rows. All layout and first-paint gates consume one sticky cost-based virtualization decision. Native segments remain at most two rows, while an expensive logical row occupies a segment alone. Ordinary orchestration cards show at most eight workers and hand overflow to the Subagents panel; nested subagent activity uses independent 40-row replacement pages; tool-group call reveals remain incremental and consume the bounded P3 headline snapshot. Collapsed descendants are excluded from body construction and Markdown prewarm, while visible assistant/plan/git Markdown participates in tail, segment, and durable top-level prewarm. Benchmark/projection logs now include total/max-row render weight and cache state. Focused tests cover a one-row 900-call group, cache-sensitive Markdown/presentation scoring, expensive-row segmentation, and bounded nested pages.

Keep native List and two-row batches, but decide whether to virtualize from estimated render cost, not top-level row count alone.

Define a deterministic render weight using at least:

  • top-level row count;
  • assistant/plan/git Markdown bytes and block structure;
  • tool-group call count;
  • orchestration worker count;
  • nested descendant count and depth;
  • special-card kind;
  • cold versus warm presentation/Markdown cache state.

A short transcript with one 900-call group should therefore take the virtualized path. Continue reserving height from the existing estimates, but allow one expensive logical row to occupy its own native segment rather than sharing a two-row mount with another expensive row.

Additional bounds:

  • cap ordinary OrchestrationCard inline workers, matching the delegated-plan card's limit;
  • retain incremental reveal for tool-group children, but avoid recomputing the complete group's headline on each 40-row reveal;
  • paginate or virtualize expanded nested activity independently of the parent transcript;
  • keep collapsed descendants out of Markdown prewarm and body construction;
  • include always-visible plan/git Markdown in visible-segment prewarm.

P5 — Add settled-history snapshots for delta-heavy sessions

Implementation status (2026-08-01): completed turns now append compact, independently rebuildable records to transcript.render-settled.jsonl. Each record coalesces finalized prose, retains bounded tool skeletons, omits obsolete input/text deltas and hidden raw telemetry, and carries canonical plus render-index byte watermarks. Startup validates identity, schema, revert epoch, byte boundaries, and sequence continuity, seeds the incremental projector once from all valid settled turns, then pages only post-watermark index records. Canonical controller hydration remains authoritative; ordinary rendering keeps the compact seed across that handoff and rehydrates its tool payloads off-main from the decoded canonical array for exact disclosure, while advanced detail explicitly reprojects canonical history. Writers checkpoint on runFinished, resumed writers prime only the unsettled suffix, and background fallback rebuilds stream records without retaining a second event-sized array. Revert/header transfer rewrites invalidate or rebuild the companion; corruption and sequence gaps fall back without affecting the canonical transcript or P1 index. The kill switch is NUCLEIC_TRANSCRIPT_SETTLED_SNAPSHOTS=0 (or --disable-transcript-settled-snapshots). Focused tests cover multi-turn/delta compaction, seeded projection equivalence and canonical fallback, large lazy tool bodies, cursor corruption, missing snapshot rebuild, sequence discontinuity, and selected-session relaunch.

The render index should optionally checkpoint settled projected messages/turns. Once a turn is complete, store a derived representation containing finalized prose, finalized tool skeletons, and the canonical sequence range it covers.

Opening the transcript loads the latest valid checkpoint and folds only events after its watermark. This avoids decoding and re-folding hundreds of thousands of obsolete partial deltas while preserving the canonical stream for audit and exact replay. Revert, transfer, schema mismatch, or sequence discontinuity invalidates the affected checkpoint and falls back to canonical decoding.

This phase is lower priority than lazy payloads because tool/raw bytes dominate many large files, but it is required for the independent high-event-count case demonstrated by the 206,190-event sample.

5. Verification and acceptance criteria

Correctness

  • Render-index replay is display-equivalent to canonical projection for every prefix in the fixture suite, excluding intentionally lazy undisclosed bodies.
  • Expanding, copying, exporting, searching, syncing, transferring, resuming, reverting, and enabling advanced detail recover the same canonical content as before.
  • Index corruption, truncation, schema mismatch, stale offsets, and crash-between-JSONL-and-index writes fall back without losing or misordering events.
  • Preview → full history replacement keeps the visible bottom stable and never duplicates a row.
  • Demand-driven controllers preserve lock recovery, pending approvals, environment GC, and the ability to resume any persisted session.

Performance

  • Selected-tail availability is p95 below 50 ms with a warm cache, including when the newest result is multi-megabyte.
  • First visible text does not scale with undisclosed tool-result or hidden raw/native bytes.
  • Application launch does not scale with the aggregate transcript bytes of unopened terminal sessions.
  • Collapsed card construction and equality are O(1) in payload size and bounded in child count.
  • No normal visible-row promotion creates a main-thread slice longer than one 60 Hz frame budget; complex cold rows may take longer off-main but must not block input.
  • Expanding a multi-megabyte result never lays out more than the display cap and does not create a greater-than-50 ms interaction stall.
  • A delta-only streaming update does not rescan settled metadata, raw events, or card presentations.

Required measurement report

For each implementation phase, publish before/after p50, p95, and maximum values for:

  • tail availability;
  • authoritative full readiness;
  • decode time and peak retained memory;
  • projection phase time;
  • first paint;
  • card-model preparation by kind;
  • visible segment mount/layout;
  • scroll frame stalls;
  • expansion latency for ordinary, 100k, and multi-megabyte outputs.

6. Rollout and risk control

  1. Land P0 instrumentation and fixtures without behavior changes.
  2. Land the render index read-only: write/rebuild it, validate equivalence, but continue rendering from canonical events.
  3. Enable index-backed startup preview behind a feature flag with automatic canonical fallback.
  4. Move collapsed card presentation to index/presentation models one card family at a time.
  5. Enable demand-driven controller reconstruction after recovery and environment tests pass.
  6. Switch virtualization to render weight using telemetry from the preceding phases.
  7. Add settled checkpoints last, retaining canonical equivalence tests and a kill switch.

The render index and checkpoints must always be treated as disposable caches. Canonical JSONL remains the source of truth throughout the rollout, which keeps failure recovery simple and lets every phase ship independently.

7. Expected impact

  • Largest first-content win: P1, because collapsed tool/raw bytes no longer enter the visible-tail decode path.
  • Largest launch win: P2, because unopened terminal histories stop competing for CPU and memory.
  • Largest card-render win: P3, because specialized cards and SwiftUI equality become bounded and memoized.
  • Largest worst-case scroll win: P4, because logical rows with hundreds of children no longer evade virtualization.
  • Largest delta-heavy win: P5, because completed streaming fragments no longer have to be decoded and folded for ordinary display.