From dc9800bb319e92fb7266f29042afcc4fecfe787e Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 17:43:25 -0700 Subject: [PATCH] Session View Animation Nucleic-Session: A8FFA1A6-443F-444A-A735-C53958D7E570 Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index f7e3f25..4f8815d 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -494,8 +494,40 @@ 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() + private var items: [TranscriptItem] { - TranscriptProjection.build(events, showRaw: showRaw, showLockEvents: showLockEvents) + projectionCache.items(for: events, showRaw: showRaw, showLockEvents: showLockEvents) } /// How much content may still sit below the viewport's bottom edge and still count as "at