# Main-Thread Performance Plan

## Status (2026-07-31)

- **Item 2 — done.** `SessionUIProjector` (one actor per live session) reduces each
  controller stream off-main and commits batched deltas (~11/s open, ~3/s background);
  wired through `AppStore.observe`/`ingestUI`. Contract tests: `SessionUIProjectorTests`.
- **Item 3 — done.** `SessionController.ingest` stamps `updatedAt` only for user-visible
  activity or a status transition; `lastSeq` alone advances on streaming deltas, so
  summaries are value-identical during a token burst and `upsertSummary`'s no-op guard
  stops the sidebar/Recents/dashboard invalidation at the source. Regression test:
  `streamingDeltasDoNotRestampUpdatedAt`. (The ID-keyed summary index was judged not
  worth the churn once upserts dropped to one per coalesced batch.)
- **Item 4 — done, including off-main projection.**
  `IncrementalTranscriptProjection` (stable-prefix sealing, live-tail refold, full-fold
  fallback on any doubt) is owned by a per-open-chat `TranscriptProjectionWorker` actor.
  Projection, display filtering, auth cleanup, and segment-range construction all run away from
  the main actor; SwiftUI reads only the worker's immutable cached result. A worker is replaced on
  selection, so an abandoned giant fold cannot serialize the newly opened chat behind it. During
  startup, the bounded tail preview remains interactive while authoritative full-history projection
  completes. Prefix-equivalence suite: `IncrementalTranscriptProjectionTests`; preview-to-full
  worker replacement is covered by `TranscriptRenderSegmenterTests`.
  Extended 2026-07-28: (a) the store's throttled summary pass
  (`AppStore.regenerateToolGroupSummaries` and its staleness guards) now derives its units
  through the same projector (`summarizableUnits(events:)`, a second lazily-primed consumer
  of the shared seam) instead of refolding the whole open transcript per invocation; (b) the
  per-read cost during a long subagent run is bounded — the tail's partition is cached and
  appended to (`TailCache`), each open scope's closed children seal under a per-scope
  projector (`ScopeChildProjection`, recursive, cursor-verified against late events into
  result-fenced scopes), and a pinned watermark records *why* it can't advance
  (`WatermarkHint`) so quiet ticks validate only the new events instead of rescanning the
  unsealed region. Previously the open Task's whole scope refolded on every display tick —
  O(scope²) across the run. Extended 2026-07-31/2026-08-01: render rows use stable two-row batches
  (reduced from twelve, then six, after cold-open tuning). Direct
  `LazyVStack` virtualization was rejected after completed transcripts demonstrated estimate
  corrections while scrolling: mixed-height Markdown/tool batches moved the document even with no
  live output. Extended 2026-08-01: histories beyond 96 projected rows now use those batches as
  native macOS `List` rows. Its AppKit-backed reuse removes off-screen SwiftUI/TextKit hierarchies
  while retaining resolved variable row heights; shorter histories keep exact eager geometry.
  Segment ranges append incrementally, and Equatable boundaries let settled subtrees and sizes be
  reused while only the live batch changes. Initial/passive following keys off the rendered tail
  revision (so autoship notes and other log entries count as the end); the scroll surface's first
  appearance is gated by `AppStore.openTranscriptAvailableSessionID`: during cold startup a
  reverse 48-event/128-KiB cursor can publish the newest canonical tail before the controller's
  full decode completes; `openTranscriptReadySessionID` remains the separate authoritative edge
  raised after the ordered snapshot→history pull reaches the controller's known tail. Unread-marker writes, Nash viewer
  hydration, and whole-history tool-summary bookkeeping follow that readiness edge instead of
  withholding the canvas. Continuous scroll geometry is quantized into three threshold regions
  before it reaches the main actor. Long-history top-level Markdown warms continuously
  newest-to-oldest on two bounded utility-QoS lanes. This replaces one detached task, signpost,
  actor round-trip, and set mutation per two-row segment. Native rows have local readiness state:
  a visible row performs an independent user-initiated cache fill while the background walk keeps
  advancing, and completing it does not invalidate the root transcript or every other List row.
  Recycling cancels only that row's cache fill; there is no cancel/restart priority barrier.
  Off-main projection likewise continues during interaction/deceleration, while canceled obsolete
  preview revisions are rejected before they can queue another whole-history fold. After
  the first real tail chunk exists, an explicit unanimated pin
  targets the fixed sentinel below all prose, autoship/lock/status rows, and bottom padding rather
  than relying on `List`'s early estimated bottom. Every segment has a cheap row-count `length` and
  an immediate transparent slot. A derived, per-session
  binary sidecar retains measured vertical lengths by content width/filter/revert epoch, but loads
  opportunistically and never gates first text. Its in-memory form is dictionary-backed and
  unobserved: height lookup/recording is O(1), and measuring a row no longer writes root `@State` or
  rebuilds the whole List. Cold misses use content-derived estimates computed with projection
  off-main, so a multi-page response reserves roughly its actual canvas instead of 48 points and
  no longer makes the document grow repeatedly during a fast scroll. Unmounted slots draw only a
  constant-size pair of neutral bars. Placeholder and real content are exclusive, unanimated
  branches—no fake-text typesetting, gradient mask, blur, infinite sweep, or simultaneous
  focused/unfocused layers. Collapsed tool/subagent descendants remain outside the mount path and
  join the background-priority durable snapshot. `TranscriptProjection` and `MarkdownPrewarm`
  retain coarse instrumentation without per-chunk logging overhead. Contracts:
  `TranscriptRenderSegmenterTests`, `TranscriptRenderLengthCacheTests`,
  `TranscriptScrollFollowPolicyTests`,
  `AppStoreTests.transcriptBecomesReadyBeforeNashHistoryFinishesLoading`.
- **Transcript Markdown cold-open cache — done.** Assistant prose is prewarmed off-main. Both
  layout modes warm only Markdown directly present in their first top-level render set before
  layout; long virtualized histories limit that set to the newest two-row segment. Collapsed nested
  streams and the full disk snapshot load/refresh at background priority after reveal. A
  per-session, schema-versioned binary sidecar under
  Application Support stores renderer-neutral inline runs (text, presentation intent, link), keyed
  by the message SHA-256; it never archives SwiftUI views, fonts, widths, or row geometry. Files are
  atomic, owner-only, excluded from backup, and bounded by a 128 MiB/96 MiB high/low-water budget.
  `TranscriptFirstPaint` spans selection through visible, bottom-pinned text and logs the projected
  row count/layout mode; `MarkdownPrewarm` plus structural hit/miss logs split memory hits, disk hits,
  and real Foundation Markdown parses. Contracts: `MarkdownRenderDiskCacheTests`.
- **Item 5 — done.** Literal `type` payloads over the host surface are bounded
  (`MacVMEngine.typeLongSurfaceText`): text ≤ 64 chars keeps the direct low-latency path;
  longer text routes through the in-guest agent's `type` op when available (typed in-guest,
  no host main-actor cost), else is synthesized in 32-character chunks with a 10 ms
  suspension between them. The chunk loop runs on the (cancellable) MCP handler task, so a
  stopped action ends at the next chunk boundary; chunks split only between complete
  strokes, so no modifier is left held. Tests: `typeChunksAreBoundedAndLossless`,
  `directTypeThresholdKeepsShortTextOnTheDirectPath`.
- **Item 6 — done.** The launch `.task` keeps project/session hydration strictly ordered
  (`loadProjects` → `loadSessions(deferringStartupMaintenance: true)`), then runs
  activity-feed seeding, todo loading, and provider probing concurrently (`async let`).
  `loadSessions` Phase 2 now reads transcripts through a three-slot bounded pool and installs
  each reconstructed controller as soon as its transcript is ready. A chat selected while cold
  hydration is still running receives a foreground overflow slot and is explicitly reloaded as
  soon as its controller exists, so it cannot sit behind unrelated long transcripts. Archived
  transcripts form a deferred tier: none begins while an active transcript is still hydrating,
  and they run at background QoS unless the user explicitly opens one. Transcript
  JSONL decoding is chunked to avoid additional whole-file/line-array copies; DB cache writes
  follow the observable install. Lock/nvrsion reconciliation runs on its own store-owned task;
  commands —
  `createSession` and all three send seams — gate on `awaitStartupLockReconciliation()`
  instead of the reconcile blocking hydration. Container and VM-disk GC reconcile
  concurrently (`reconcileSessionEnvironments`), and the rest of the maintenance tail
  (transfer recovery, Orchestra-orphan + scratch-dir cleanup, keyword backfill, stale-base
  re-provision) runs exactly once after first paint via `runDeferredStartupMaintenanceOnce()`
  — ordered after the lock gate so an orphan delete can never race `reconstructLocks`.
  Non-launch callers (revert/undo reloads, nucleicd, tests) keep the original inline tail via
  the default parameter. Startup signposts (`StartupSignposts`, subsystem `com.nucleic`,
  category `startup`): `LaunchToFirstFrame`, `Hydration`, `LockReconcile`,
  `DeferredMaintenance` — this is item 9's "startup hydration and maintenance"
  instrumentation slice.
- **Item 7 — done.** `MacVMEngine.changes()` publishes value-deduped, name-sorted
  `[MacVMEntry]` snapshots (latest-wins buffering, MergeQueue's subscriber idiom) from every
  registry mutation — boot, readiness/IP flip, stop, suspend (RAM/disk), resume, guest
  power-off — re-exposed via `MacVMManager.vmChanges()` / `AppStore.macVMChanges()` and primed
  on subscription. `RootView.watchAutoVMMonitor` now for-awaits that stream (chat switches,
  settings flips, and PiP fan picks re-derive via their own triggers against the cached
  snapshot) with a 30 s reconciliation backstop replacing the 2 s poll; every derived
  assignment is compared first. The remaining 2 s loops were left deliberately: the
  VM-monitor/PiP capture loops are frame-cadence work, and the Settings VM tab / Control panel
  polls aggregate base-build/lock/autoship state beyond the registry (Control already
  no-op-guards its snapshot). Contract test: `registryChangeStreamPrimesEachSubscriberWithCurrentSnapshot`.
- **Item 8 — done.** `BuildRunRunner` drains/decodes via an off-main `ConsoleBuffer`
  actor and applies bounded batches (~12/s) with a guaranteed final flush.
- **Related (hang-report driven):** every `NSSavePanel`/`NSOpenPanel` `runModal` call is
  now async (`begin()` / `beginSheetModal`) — the Jul 18 00:39 hang report caught
  `exportMeshLogs` wedging the app in a nested modal run loop. The two
  `DispatchSemaphore` waits (`LoginShellPATH`, `OAuthLoopback`) were audited: both are
  reachable only from actor executors, never the main actor.
- Items 1 and 9 remain (item 9's startup-instrumentation half is already covered by item 6's
  `StartupSignposts`; the remaining signposts and the scenario/frame-time measurement pass are
  still open).

## Objective

Keep the macOS desktop UI responsive while several sessions and virtual machines are active. The
target is frame-time stability, not merely low aggregate CPU usage: background work must not create
long or continuous slices on the main actor.

## Completed foundation

The first VM-specific changes landed on `dev` in merge commit `a3cff9613`:

- VM boot now registers a host surface without immediately creating a `VZVirtualMachineView`,
  off-screen `NSWindow`, or WindowServer render workload.
- Capture, HID, and operator viewing materialize the AppKit surface on demand.
- An inactive, off-screen surface is released after a five-second grace period while retaining the VM
  registration and cursor state for later reactivation.
- All VM framebuffer captures share one global 30-grab-per-second budget. A single monitor retains its
  current cadence; multiple monitors divide the budget instead of multiplying synchronous
  `cacheDisplay` calls.
- The merge preserved `dev`'s queued mouse-gesture implementation, which avoids blocking in AppKit's
  mouse-tracking loop.

## Remaining work, in order

### 1. Verify and instrument the VM surface lifecycle

Before expanding the concurrency changes, establish that the new lifecycle is correct and quantify
its effect.

- Build the macOS app and run the full Swift test suite.
- Add debug counters or signposts for registered surfaces, materialized surfaces, framebuffer grabs,
  and main-thread capture duration.
- Add coverage for these transitions:
  - headless VM boot: registered, no AppKit surface;
  - first monitor capture: surface materializes and produces a nonblank frame;
  - monitor disappears: surface releases after the grace period;
  - later capture or HID action: surface rematerializes;
  - operator-assist window: never reaped while on screen;
  - VM stop/restart during activation or capture: no stale view survives;
  - suspended VM: frozen frame remains available without keeping a live surface.
- Manually compare UI frame pacing with zero, one, and several VMs, both with PiP hidden and visible.

Acceptance criteria:

- A VM used only by `mac_vm_exec` has no materialized `VZVirtualMachineView` after the grace period.
- Aggregate `cacheDisplay` frequency never exceeds the global budget.
- Computer-use input, diagnostic viewing, PiP, suspension, and resume continue to work.
- No surface-related main-actor slice exceeds one display-frame budget under normal capture.

### 2. Move per-session event reduction off the main actor

`AppStore.observe` currently creates one task per session from an `@MainActor` context. Every event
from every active session therefore enters the same serialized `ingestUI` path, even when the session
is in the background.

Implement a `SessionUIProjector` actor (one per session, or an equivalently isolated keyed service):

1. Subscribe to the `SessionController` stream outside `AppStore`'s main-actor executor.
2. Reduce raw events into a small `Sendable` UI delta containing only changed summary fields and any
   open-session transcript tail.
3. Publish immediately for semantic boundaries:
   - approval requested or resolved;
   - user question;
   - error;
   - run/session status transition;
   - terminal event.
4. Coalesce ordinary streaming text, thinking, usage, and progress events:
   - open session: target 10–12 UI commits per second;
   - background session: target 2–4 commits per second;
   - always flush the latest pending delta before a terminal event.
5. Apply the resulting batch in one short main-actor transaction.

Keep canonical transcript writing, event order, sync delivery, conflict arbitration, and persistence
semantics unchanged. UI coalescing must never discard canonical events.

Required tests:

- event order and terminal flush;
- immediate approval/error delivery during a streaming burst;
- cancellation and session deletion with a pending batch;
- several sessions projecting concurrently without cross-session state leakage;
- open-session switching while old-session work is in flight;
- equivalence of final summaries before and after coalescing.

### 3. Stop invalidating the whole sidebar for every event

`SessionController` advances `lastSeq` and `updatedAt` for each canonical event. Reconstructing and
writing the full `SessionSummary` consequently invalidates the observable summaries collection during
ordinary token streaming.

- Separate canonical event freshness from sidebar-visible activity fields.
- Publish sidebar ordering timestamps only for meaningful user-visible activity, not every text delta.
- Replace repeated linear `firstIndex` summary updates with an ID-keyed index or stable per-session row
  models.
- Batch summary mutations from step 2 so one scheduler turn produces one observable update.
- Ensure recents ordering, unread completion, approvals, favorites, archive state, and mesh summaries
  still update immediately when their visible value changes.

Acceptance criterion: a background session streaming prose must not cause full-sidebar recomputation at
token cadence.

### 4. Incrementalize macOS transcript projection — completed

The open chat currently pulls and reassigns full history, then performs an O(n) projection whenever the
transcript version changes. Port the stable-prefix incremental projection already implemented for iOS.

- Seal completed turns and immutable tool/message groups.
- Re-project only the live tail for each streaming delta.
- Keep late lock-note folding correct across the sealed/live boundary.
- Move pure projection work to a non-main actor and return immutable projected segments to SwiftUI.
- Preserve a full-fold fallback when stream identity changes, history shrinks, or a sealed identifier is
  referenced unexpectedly.
- Add prefix-by-prefix equivalence tests against `TranscriptProjection.items` for representative
  Claude, Codex, ACP, subagent, approval, error, and lock streams.

After projection is incremental, keep stable render batches on both layout paths: exact eager layout
through 96 rows, then native macOS list virtualization for longer histories. Do not restore direct
lazy-stack virtualization; its changing mixed-content estimates regress inertial scrolling and
initial bottom anchoring.

### 5. Bound main-actor VM text injection

Literal VM typing currently synthesizes every character in one main-actor call.

- Route long text through the guest agent or pasteboard path when available.
- For synthesized input, send bounded chunks and yield between them.
- Keep key chords and short text on the low-latency direct path.
- Add cancellation handling so a stopped computer-use action does not continue typing.

Acceptance criterion: typing a large payload must not block host scrolling or keystrokes for the
duration of the payload.

### 6. Parallelize noncritical startup work

Keep project and session hydration ordered, then split independent work from the first-paint path.

- After sessions are installed, run activity-feed seeding, todo loading, and provider probing
  concurrently where their data dependencies allow it.
- Run container reconciliation and VM-disk reconciliation concurrently.
- Defer stale-base checks, orphan cleanup, and other maintenance until after the first window paint.
- Keep commands gated until any required lock/nvrsion reconciliation is complete rather than blocking
  unrelated UI hydration.
- Install reconstructed controllers incrementally on the main actor, with a user-selected queued
  transcript promoted ahead of background hydration.

Add a startup signpost spanning process launch to first interactive frame, plus separate spans for
hydration and deferred maintenance.

### 7. Replace polling with change streams

- Publish VM registry changes from `MacVMManager`/`MacVMEngine` through an `AsyncStream` and replace the
  two-second `RootView.watchAutoVMMonitor` poll.
- Reuse the same stream for settings and Control-panel VM lists where possible.
- Keep a low-frequency reconciliation poll only as a defensive backstop.
- Avoid assigning observable state when the derived value is unchanged.

### 8. Batch high-volume process output

`BuildRunRunner` appends output to main-actor observable state one line at a time.

- Drain and decode output off-main.
- Publish bounded batches on a short cadence or at process completion.
- Preserve stdout/stderr identity and the existing maximum-line cap.
- Apply the same pattern to any other live console that publishes per-line observable mutations.

### 9. Measure against frame-time acceptance criteria

Add signposts around:

- `AppStore.ingestUI` and the replacement projector/commit phases;
- sidebar summary commits;
- open-transcript projection and row layout;
- VM surface activation/release;
- `cacheDisplay`, encoding, and decode;
- VM HID and long-text injection;
- startup hydration and maintenance.

Exercise at least these scenarios:

1. One foreground session streaming a long response.
2. Four background sessions streaming concurrently.
3. Two or more running VMs with monitors hidden.
4. Two or more visible VM monitors/PiP cards.
5. A long transcript streaming while background sessions and VMs are active.
6. Long VM text injection while scrolling and typing in the host app.

Track p50, p95, and maximum main-thread slice duration, dropped frames, event-to-visible latency for
approvals, and aggregate framebuffer grabs. Optimize for p95/max frame time rather than total CPU.

## Guardrails

- AppKit and SwiftUI mutations remain on the main actor; move reduction and pure computation, not UI
  object access.
- Canonical event durability and ordering remain lossless even when UI updates are coalesced.
- Approval, question, error, and terminal state must never wait behind a cosmetic throttle window.
- Do not introduce an unbounded task, continuation, frame, or event queue.
- Every cross-actor result must be `Sendable` or an explicitly reviewed single-owner handoff.
- Preserve `dev`'s queued mouse-gesture path; synchronous `window.sendEvent` for down/up sequences can
  reintroduce the AppKit tracking-loop deadlock.
