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:
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user