diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index e5f3b16..82482a8 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -494,37 +494,15 @@ struct TranscriptList: View { /// settling — a `.task(id:)` debounces it to detect when the opening layout has come to rest. @State private var transcriptContentHeight: CGFloat = 0 - /// Memoized transcript projection. `TranscriptProjection.build` folds the entire raw event - /// stream into rows on every read, but `body` re-evaluates far more often than `events` - /// actually changes: while the chat is opening, `onScrollGeometryChange` bumps - /// `transcriptContentHeight` on every settling-layout frame, and each bump re-runs `body` — - /// so the untouched event stream was re-folded dozens of times during the exact window the - /// tab bar is sliding away, starving that animation of the main thread and making the - /// load-in jitter. Reuse the last fold whenever the stream is unchanged. A class held in - /// `@State` so reading/updating it from `body` doesn't itself invalidate the view (SwiftUI - /// stores the reference and never diffs its interior). - private final class ProjectionCache { - /// The event stream is append-only with strictly increasing `seq`, so `(count, lastSeq)` - /// uniquely pins it; `firstSeq` guards the open reset (stream cleared to `[]`, then - /// refilled). The two display toggles are inputs to the fold, so they key it too. - private struct Key: Equatable { - var count: Int; var firstSeq: UInt64; var lastSeq: UInt64 - var showRaw: Bool; var showLockEvents: Bool - } - private var key: Key? - private var value: [TranscriptItem] = [] - - func items(for events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] { - let k = Key(count: events.count, firstSeq: events.first?.seq ?? 0, - lastSeq: events.last?.seq ?? 0, showRaw: showRaw, showLockEvents: showLockEvents) - if key == k { return value } - let built = TranscriptProjection.build(events, showRaw: showRaw, showLockEvents: showLockEvents) - key = k - value = built - return built - } - } - @State private var projectionCache = ProjectionCache() + /// Incremental transcript projection (docs/TRANSCRIPT_INCREMENTAL_PROJECTION.md). Two layers: + /// a read memo that collapses the redundant `body` re-evaluations (settling layout bumps + /// `transcriptContentHeight` every frame while opening, and each bump re-runs `body` against + /// an unchanged stream), and — when the stream *has* grown — a stable-prefix fold that seals + /// everything before the live turn once and re-folds only the tail, so a streaming delta + /// costs O(live-tail) instead of re-folding all N events (which made long sessions cost + /// O(N²) over their lifetime). A class held in `@State` so reading/updating it from `body` + /// doesn't itself invalidate the view (SwiftUI stores the reference, never diffs interior). + @State private var projectionCache = IncrementalTranscriptProjection() /// The in-flight background Markdown pre-warm (below), cancelled when a newer one supersedes /// it or the transcript goes away — so a long warm can't outlive the view or stack up behind