From 17e904eff12a39869e168c1eb5aa79a518b03541 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:03:28 -0700 Subject: [PATCH] nvrsion: Add streaming design change to plan doc and start markdown pre-warm in docs/TRANSCRIPT_INCREMENTAL_PROJECTION.md, ios/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift, and ios/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift. Nucleic-Promote: 1 Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 32 +++++++ .../Views/Transcript/MarkdownText.swift | 87 ++++++++++++++++--- 2 files changed, 105 insertions(+), 14 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 4f8815d..e5f3b16 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`. @@ -625,6 +651,12 @@ struct TranscriptList: View { // so it sits over the composer instead of behind the floating bar; a tap bumps this // same request, and following resumes once the geometry reader sees the bottom. .onChange(of: scrollToBottomRequest) { scrollToEnd(proxy) } + // Warm the Markdown parse caches off the main thread whenever the row set grows — + // `initial: true` fires it for the batch that lands on open (the expensive case), and + // each later new row tops it up. Keyed on the row *count*, so streaming deltas into an + // existing row (which don't change the count) never re-arm it. `items.count` is O(1). + .onChange(of: items.count, initial: true) { _, _ in prewarmMarkdown() } + .onDisappear { prewarmTask?.cancel() } } } diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift b/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift index 4655543..4ea8584 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift @@ -167,26 +167,54 @@ struct MarkdownText: View { .lineSpacing(4) } + /// One prose line's classification: the exact string that gets inline-parsed — the cache key + /// `prewarm` must match — paired with how it's styled. Single source of truth for both the + /// live `lineView` and the off-main `prewarm`, so a warmed key can never drift from the key + /// the layout later looks up (any drift and the warm entry would silently miss). + private enum LineStyle { + case blank + case heading(content: String, scale: CGFloat, weight: Font.Weight) + case bullet(content: String) + case plain(content: String) + + /// The string handed to `inline(_:)`, or nil for a line that renders without a parse. + var inlineContent: String? { + switch self { + case .blank: return nil + case .heading(let c, _, _), .bullet(let c), .plain(let c): return c + } + } + } + + /// Classify one raw prose line. Headings are checked before bullets (a heading marker wins), + /// and the plain case keeps the *raw* line (not the trimmed one) exactly as the old cascade + /// did — the inline parser preserves leading whitespace under `.inlineOnlyPreservingWhitespace`. + private static func classify(_ raw: String) -> LineStyle { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return .blank } + // Headings scale relative to the base prose size so the hierarchy holds at any base and + // never collapses to the body size. + if trimmed.hasPrefix("### ") { return .heading(content: String(trimmed.dropFirst(4)), scale: 1.13, weight: .semibold) } + if trimmed.hasPrefix("## ") { return .heading(content: String(trimmed.dropFirst(3)), scale: 1.28, weight: .bold) } + if trimmed.hasPrefix("# ") { return .heading(content: String(trimmed.dropFirst(2)), scale: 1.5, weight: .bold) } + if let bullet = bulletContent(trimmed) { return .bullet(content: bullet) } + return .plain(content: raw) + } + @ViewBuilder private func lineView(_ raw: String) -> some View { - let trimmed = raw.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty { + switch Self.classify(raw) { + case .blank: Color.clear.frame(height: 3) - } else if trimmed.hasPrefix("### ") { - // Headings scale relative to the base prose size so the hierarchy holds - // at any base and never collapses to the body size. - inline(String(trimmed.dropFirst(4))).font(.system(size: bodySize * 1.13, weight: .semibold)) - } else if trimmed.hasPrefix("## ") { - inline(String(trimmed.dropFirst(3))).font(.system(size: bodySize * 1.28, weight: .bold)) - } else if trimmed.hasPrefix("# ") { - inline(String(trimmed.dropFirst(2))).font(.system(size: bodySize * 1.5, weight: .bold)) - } else if let bullet = Self.bulletContent(trimmed) { + case .heading(let content, let scale, let weight): + inline(content).font(.system(size: bodySize * scale, weight: weight)) + case .bullet(let content): HStack(alignment: .firstTextBaseline, spacing: 6) { Text("•").foregroundStyle(.secondary) - inline(bullet) + inline(content) } - } else { - inline(raw) + case .plain(let content): + inline(content) } } @@ -230,4 +258,35 @@ struct MarkdownText: View { inlineCache.setObject(InlineBox(parsed), forKey: key) return parsed } + + // MARK: - Pre-warming + + /// Populate the block and inline caches for `markdown` ahead of layout. `AttributedString(markdown:)` + /// is the dominant per-row cost when a long transcript first lays out, and the eager `VStack` + /// runs it for every prose line synchronously — right as the session is pushed and the tab bar + /// is sliding away, which is what makes the load-in jitter. Calling this off the main thread + /// (see `TranscriptList`) does that parsing in the background so the first layout hits ready + /// results instead. + /// + /// Safe to call from any thread and redundantly: the caches are `NSCache` (thread-safe) and + /// keyed only by the source string (independent of `bodySize`), so a warm value equals what + /// the main thread would compute, and a repeat call is a cheap cache hit. Cooperatively + /// cancellable — a huge transcript's warm loop bails the moment its owning task is cancelled. + static func prewarm(_ markdown: String) { + for block in parse(markdown) { // also warms the block cache + if Task.isCancelled { return } + switch block { + case .code: + break // a code block renders via `Text(code)` — no inline parse to warm + case .text(let text): + for raw in text.components(separatedBy: "\n") { + if let content = classify(raw).inlineContent { _ = attributedInline(content) } + } + case .table(let rows): + for row in rows where !Task.isCancelled { + for cell in row { _ = attributedInline(cell) } + } + } + } + } }