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 <[email protected]>
This commit is contained in:
2026-07-06 18:03:28 -07:00
co-authored by Nucleic
parent 0c7fc71331
commit 17e904eff1
2 changed files with 105 additions and 14 deletions
@@ -526,10 +526,36 @@ struct TranscriptList: View {
} }
@State private var projectionCache = ProjectionCache() @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<Void, Never>?
private var items: [TranscriptItem] { private var items: [TranscriptItem] {
projectionCache.items(for: events, showRaw: showRaw, showLockEvents: showLockEvents) 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 /// 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 bottom" — a little slack so streaming deltas and rounding never flip the gate. Mirrors
/// the Mac's `bottomFollowThreshold`. /// 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 // 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. // same request, and following resumes once the geometry reader sees the bottom.
.onChange(of: scrollToBottomRequest) { scrollToEnd(proxy) } .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() }
} }
} }
@@ -167,26 +167,54 @@ struct MarkdownText: View {
.lineSpacing(4) .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 @ViewBuilder
private func lineView(_ raw: String) -> some View { private func lineView(_ raw: String) -> some View {
let trimmed = raw.trimmingCharacters(in: .whitespaces) switch Self.classify(raw) {
if trimmed.isEmpty { case .blank:
Color.clear.frame(height: 3) Color.clear.frame(height: 3)
} else if trimmed.hasPrefix("### ") { case .heading(let content, let scale, let weight):
// Headings scale relative to the base prose size so the hierarchy holds inline(content).font(.system(size: bodySize * scale, weight: weight))
// at any base and never collapses to the body size. case .bullet(let content):
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) {
HStack(alignment: .firstTextBaseline, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("•").foregroundStyle(.secondary) Text("•").foregroundStyle(.secondary)
inline(bullet) inline(content)
} }
} else { case .plain(let content):
inline(raw) inline(content)
} }
} }
@@ -230,4 +258,35 @@ struct MarkdownText: View {
inlineCache.setObject(InlineBox(parsed), forKey: key) inlineCache.setObject(InlineBox(parsed), forKey: key)
return parsed 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) }
}
}
}
}
} }