Merge nucleic/upbeat-maple-ferret-jov0 into dev

This commit is contained in:
2026-07-31 19:40:51 -07:00
parent 84fc9c8cad
commit 373789efc3
2 changed files with 101 additions and 40 deletions
+94 -35
View File
@@ -1444,7 +1444,9 @@ struct SessionDetailView: View {
TranscriptLoadingGlyphs(
seed: TranscriptLoadingGlyphs.seed(
for: store.openSessionID?.rawValue),
lineCount: TranscriptRenderSegmenter.rowsPerSegment * 2)
rowCount: TranscriptRenderSegmenter.rowsPerSegment,
fontSize: transcriptFontSize,
active: !transcriptVisible)
.frame(maxWidth: contentMaxWidth, alignment: .leading)
.padding(.horizontal, transcriptInset)
.padding(.bottom, transcriptBottomPadding)
@@ -1763,6 +1765,7 @@ struct SessionDetailView: View {
TranscriptRenderSegmentSlot(
seed: segment.id,
length: segment.length,
fontSize: transcriptFontSize,
reservedHeight: reservedHeight,
mounted: mountedTranscriptSegmentIDs.contains(segment.id),
// The virtualized path is itself the progressive reveal. Its newest chunk
@@ -2646,11 +2649,23 @@ struct SessionDetailView: View {
/// A deliberately cheap, deterministic stand-in for prose that has not mounted yet.
///
/// The characters look random but are derived from the session/segment identity, so unrelated
/// SwiftUI updates cannot make the placeholder flicker. A small fixed number of one-line `Text`
/// views is substantially cheaper than constructing the Markdown/tool hierarchy it represents.
/// SwiftUI updates cannot make the placeholder flicker. A small fixed number of two-line prose
/// groups is substantially cheaper than constructing the Markdown/tool hierarchy it represents.
private struct TranscriptLoadingGlyphs: View {
private struct SweepToken: Hashable {
let seed: Int
let active: Bool
let reduceMotion: Bool
}
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let seed: Int
let lineCount: Int
let rowCount: Int
let fontSize: CGFloat
let active: Bool
@State private var sweepAtTrailingEdge = false
static func seed(for value: String?) -> Int {
// FNV-1a gives a stable seed across launches; Swift's `hashValue` is intentionally
@@ -2662,7 +2677,7 @@ private struct TranscriptLoadingGlyphs: View {
return Int(truncatingIfNeeded: hash)
}
private static func makeLines(seed: Int, count: Int) -> [String] {
private static func makeRows(seed: Int, count: Int) -> [String] {
let glyphs = Array("abcdefghijklmnopqrstuvwxyz0123456789")
var state = UInt64(bitPattern: Int64(truncatingIfNeeded: seed))
^ 0x9E37_79B9_7F4A_7C15
@@ -2675,11 +2690,7 @@ private struct TranscriptLoadingGlyphs: View {
return state &* 2_685_821_657_736_338_717
}
return (0..<max(1, count)).map { lineIndex in
// Varied line lengths read like prose once blurred. The last line is usually shorter,
// which keeps the silhouette from looking like a stack of uniform progress bars.
var length = 22 + Int(next() % 39)
if lineIndex == max(1, count) - 1 { length = max(14, length / 2) }
func line(length: Int) -> String {
var characters: [Character] = []
characters.reserveCapacity(length)
for column in 0..<length {
@@ -2691,35 +2702,74 @@ private struct TranscriptLoadingGlyphs: View {
}
return String(characters)
}
return (0..<max(1, count)).map { _ in
// Two normal-leading prose lines approximate one typical transcript row. Row groups
// are separated by the transcript's actual six-point stack spacing below.
let firstLength = 28 + Int(next() % 33)
let secondLength = max(16, (22 + Int(next() % 31)) * 3 / 4)
return line(length: firstLength) + "\n" + line(length: secondLength)
}
}
var body: some View {
let lines = Self.makeLines(seed: seed, count: lineCount)
VStack(alignment: .leading, spacing: 8) {
ForEach(lines.indices, id: \.self) { index in
Text(lines[index])
.lineLimit(1)
@ViewBuilder
private func glyphStack(_ rows: [String]) -> some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(rows.indices, id: \.self) { index in
Text(rows[index])
.font(.system(size: fontSize))
.lineLimit(2)
.truncationMode(.tail)
}
}
.font(.system(size: 13, weight: .medium, design: .monospaced))
.foregroundStyle(LinearGradient(
colors: [
Color.accentColor.opacity(0.72),
AppTheme.orchestra.opacity(0.42),
Color.accentColor.opacity(0.58),
],
startPoint: .leading,
endPoint: .trailing))
.blur(radius: 2.8)
.shadow(color: Color.accentColor.opacity(0.34), radius: 7)
.shadow(color: AppTheme.orchestra.opacity(0.16), radius: 12)
.opacity(0.72)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
.textSelection(.disabled)
.accessibilityHidden(true)
.allowsHitTesting(false)
}
var body: some View {
let rows = Self.makeRows(seed: seed, count: rowCount)
glyphStack(rows)
// Match real transcript prose exactly: same regular system face, base size, primary
// text color, and six-point spacing between logical rows. Blur is the only baseline
// distinction from content that has finished mounting.
.foregroundStyle(AppTheme.primaryText.opacity(0.42))
.overlay {
GeometryReader { geometry in
let bandWidth = max(72, geometry.size.width * 0.22)
LinearGradient(
colors: [
.clear,
AppTheme.primaryText.opacity(0.88),
.clear,
],
startPoint: .leading,
endPoint: .trailing)
.frame(width: bandWidth)
.offset(x: sweepAtTrailingEdge
? geometry.size.width + bandWidth
: -bandWidth)
}
.mask { glyphStack(rows) }
.shadow(color: AppTheme.primaryText.opacity(0.28), radius: 7)
}
.blur(radius: 2.4)
.opacity(0.82)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.disabled)
.accessibilityHidden(true)
.allowsHitTesting(false)
.task(id: SweepToken(seed: seed, active: active, reduceMotion: reduceMotion)) {
var reset = Transaction(animation: nil)
reset.disablesAnimations = true
withTransaction(reset) { sweepAtTrailingEdge = false }
guard active, !reduceMotion else { return }
// Let the placeholder draw once at its neutral resting state, then keep a narrow
// light band moving across it. Only the overlay offset animates; layout stays inert.
await Task.yield()
guard !Task.isCancelled else { return }
withAnimation(.linear(duration: 1.55).repeatForever(autoreverses: false)) {
sweepAtTrailingEdge = true
}
}
}
}
@@ -2738,6 +2788,7 @@ private struct TranscriptRenderSegmentSlot<Content: View>: View {
let seed: Int
let length: Int
let fontSize: CGFloat
let reservedHeight: CGFloat
let mounted: Bool
let reveal: Bool
@@ -2751,6 +2802,7 @@ private struct TranscriptRenderSegmentSlot<Content: View>: View {
init(
seed: Int,
length: Int,
fontSize: CGFloat,
reservedHeight: CGFloat,
mounted: Bool,
reveal: Bool,
@@ -2761,6 +2813,7 @@ private struct TranscriptRenderSegmentSlot<Content: View>: View {
) {
self.seed = seed
self.length = length
self.fontSize = fontSize
self.reservedHeight = reservedHeight
self.mounted = mounted
self.reveal = reveal
@@ -2786,7 +2839,10 @@ private struct TranscriptRenderSegmentSlot<Content: View>: View {
.blur(radius: visible ? 0 : 5.5)
.overlay(alignment: .topLeading) {
TranscriptLoadingGlyphs(
seed: seed, lineCount: max(1, min(12, length * 2)))
seed: seed,
rowCount: length,
fontSize: fontSize,
active: !visible)
.frame(
maxWidth: .infinity,
minHeight: max(reservedHeight, CGFloat(length)),
@@ -2802,7 +2858,10 @@ private struct TranscriptRenderSegmentSlot<Content: View>: View {
// already incorporates it; retaining the dependency here documents that this is
// a logical chunk slot, not an arbitrary loading spinner/skeleton.
TranscriptLoadingGlyphs(
seed: seed, lineCount: max(1, min(12, length * 2)))
seed: seed,
rowCount: length,
fontSize: fontSize,
active: true)
.frame(height: max(reservedHeight, CGFloat(length)))
}
}
+7 -5
View File
@@ -46,11 +46,13 @@
segment has a cheap row-count `length` and an immediate transparent slot. A derived, per-session
binary sidecar retains measured vertical lengths by content width/filter/revert epoch, but loads
opportunistically and never gates first text. Cold misses use row-count estimates and measure
transparent mounted content once. Unmounted slots draw a bounded set of deterministic, blurred
glyph lines (also shown immediately while initial history loads); the placeholder blooms away as
real text locally fades and sharpens, without animating row geometry. There is no animated offset
or post-mount minimum-height correction to move visible text. Hydration pauses during scroll
gestures and prioritizes any reserved slot the user reaches. Contracts:
transparent mounted content once. Unmounted slots draw bounded deterministic glyph groups (also
shown immediately while initial history loads) in the transcript's exact 15-point regular system
face, primary prose color, normal line leading, and six-point inter-row rhythm. A narrow neutral
light sweep keeps them active; the placeholder blooms away as real text locally fades and
sharpens, without animating row geometry. There is no animated offset or post-mount minimum-height
correction to move visible text. Hydration pauses during scroll gestures and prioritizes any
reserved slot the user reaches. Contracts:
`TranscriptRenderSegmenterTests`, `TranscriptRenderLengthCacheTests`,
`TranscriptScrollFollowPolicyTests`,
`AppStoreTests.transcriptBecomesReadyBeforeNashHistoryFinishesLoading`.