# Transcript loading and rendering performance plan

*Investigated 2026-08-01. Companion to
[MAIN_THREAD_PERFORMANCE_PLAN.md](MAIN_THREAD_PERFORMANCE_PLAN.md) and
[TRANSCRIPT_INCREMENTAL_PROJECTION.md](TRANSCRIPT_INCREMENTAL_PROJECTION.md).*

**Status: in progress (P0 measurement seam started; P1 render index 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.25–1.2 seconds while projection took 0.3–8 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 60–160 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` eventually prepares every eligible local session in active projects.
  Three background decoders run concurrently, with one possible foreground overflow for the
  selected chat. Archived sessions are deferred but are still eventually decoded.
- 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

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

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

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

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.
