diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 4f8815d..f6ac216 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -526,10 +526,36 @@ struct TranscriptList: View { } @State private var projectionCache = ProjectionCache() + /// 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 + /// row churn. + @State private var prewarmTask: Task? + private var items: [TranscriptItem] { projectionCache.items(for: events, showRaw: showRaw, showLockEvents: showLockEvents) } + /// Kick off (or restart) the off-main Markdown pre-warm for the visible messages. `MarkdownText` + /// parses every prose line with `AttributedString(markdown:)` during the eager first layout — a + /// cost that lands on the main thread right as the session is pushed and the tab bar slides + /// away, jittering the load-in. Snapshot the message bodies here on the main actor (a cheap read + /// of the memoized projection), then parse them on a background task so that first layout finds + /// the caches already warm. Idempotent and self-cancelling; the parse results are the same + /// whichever thread fills the (thread-safe) caches. + private func prewarmMarkdown() { + let bodies: [String] = items.compactMap { + if case .message(_, let text) = $0.kind { return text } else { return nil } + } + guard !bodies.isEmpty else { return } + prewarmTask?.cancel() + prewarmTask = Task.detached(priority: .utility) { + for body in bodies { + if Task.isCancelled { return } + MarkdownText.prewarm(body) + } + } + } + /// How much content may still sit below the viewport's bottom edge and still count as "at /// the bottom" — a little slack so streaming deltas and rounding never flip the gate. Mirrors /// the Mac's `bottomFollowThreshold`.