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

This commit is contained in:
2026-07-31 19:50:06 -07:00
parent 373789efc3
commit 9e404ca032
2 changed files with 74 additions and 59 deletions
+68 -55
View File
@@ -90,6 +90,11 @@ struct SessionDetailView: View {
/// view invisible across them means a freshly opened chat *appears* already at the tail with
/// no on-screen scroll/jitter, instead of visibly snapping into place over those first frames.
@State private var transcriptRevealed = false
/// The session whose long-history list has been explicitly pinned to the final sentinel after
/// its first real tail chunk mounted. This is separate from the passive bottom-follow state:
/// the first native-list layout can otherwise stop at the last message while later log/status
/// rows are still resolving below it.
@State private var transcriptInitialBottomPinnedSessionID: SessionID?
/// Total scroll-content height of the open transcript, tracked live from the scroll
/// view's geometry. The reveal above waits for this to stop changing (the initial layout
/// and async tool-summary lines grow it for a beat after open), so the chat is shown only
@@ -103,9 +108,9 @@ struct SessionDetailView: View {
/// segment. It is loaded opportunistically: a cache hit can refine the reserved canvas before
/// the long-history `List` appears, but cache I/O is never a prerequisite for first text.
@State private var transcriptLengthLayouts: [TranscriptRenderLengthLayout] = []
/// Long histories begin as cheap, height-reserving slots. Segment ids enter this set one at a
/// time from the tail upward, with a real suspension between insertions so AppKit/SwiftUI never
/// receives the entire transcript's row-construction work in one main-actor slice.
/// Long histories begin as cheap, height-reserving slots. The viewport-sized tail enters this
/// set in one transaction; older segment ids join immediately when native-list visibility asks
/// for them, without constructing the entire transcript hierarchy up front.
@State private var mountedTranscriptSegmentIDs: Set<Int> = []
@AppStorage(TranscriptDisplay.showDebugKey) private var showDebugLines = false
@AppStorage(TranscriptDisplay.showLockEventsKey) private var showLockEvents = true
@@ -268,6 +273,7 @@ struct SessionDetailView: View {
.onChange(of: store.openSessionID) { _, id in
transcriptSettling = true
transcriptRevealed = false
transcriptInitialBottomPinnedSessionID = nil
transcriptContentHeight = 0
isScrolledToBottom = true
transcriptScrollPhase = .idle
@@ -1314,10 +1320,15 @@ struct SessionDetailView: View {
let session: SessionID?
let revertEpoch: UInt64
let segmentCount: Int
let revealed: Bool
let userScrolling: Bool
}
private struct TranscriptInitialBottomPinToken: Hashable {
let session: SessionID?
let tailSegmentID: Int?
let tailMounted: Bool
}
/// Only the newest render batch blocks first text. The reveal-keyed background pass warms the
/// rest after the live tail is visible, matching the six-row visual hydration cadence.
private static let initialMarkdownPrewarmDocumentLimit =
@@ -1528,9 +1539,12 @@ struct SessionDetailView: View {
private func scrollableTranscript(reserveCardSpace: Bool, containerWidth: CGFloat) -> some View {
let projection = projectedTranscript()
let usesVirtualizedLayout = TranscriptRenderSegmenter.shouldVirtualize(
rowCount: projection.items.count)
let tailSegmentID = projection.segments.last?.id
return ScrollViewReader { proxy in
Group {
if TranscriptRenderSegmenter.shouldVirtualize(rowCount: projection.items.count) {
if usesVirtualizedLayout {
virtualizedTranscript(
projection: projection,
reserveCardSpace: reserveCardSpace,
@@ -1656,6 +1670,32 @@ struct SessionDetailView: View {
}
scrollToEnd(proxy, animated: false)
}
// Native `List` can establish its initial bottom before the mounted tail's exact row
// height and trailing log/status rows have resolved. Once the first real tail chunk is
// present, explicitly target the fixed sentinel after *all* transcript rows and bottom
// padding. The jump latch keeps follow authority engaged through any final AppKit
// geometry callback, so opening lands below autoship, lock, and status linesnot merely
// below the last prose message.
.task(id: TranscriptInitialBottomPinToken(
session: store.openSessionID,
tailSegmentID: tailSegmentID,
tailMounted: tailSegmentID.map {
mountedTranscriptSegmentIDs.contains($0)
} ?? false))
{
guard usesVirtualizedLayout,
let sessionID = store.openSessionID,
transcriptInitialBottomPinnedSessionID != sessionID,
let tailSegmentID,
mountedTranscriptSegmentIDs.contains(tailSegmentID)
else { return }
await Task.yield()
guard !Task.isCancelled, store.openSessionID == sessionID else { return }
isScrolledToBottom = true
jumpingToTranscriptBottom = true
scrollToEnd(proxy, animated: false)
transcriptInitialBottomPinnedSessionID = sessionID
}
.onChange(of: isBusy) { _, busy in
if busy, isScrolledToBottom, !isUserScrollingTranscript {
scrollToEnd(proxy, animated: false)
@@ -1768,9 +1808,6 @@ struct SessionDetailView: View {
fontSize: transcriptFontSize,
reservedHeight: reservedHeight,
mounted: mountedTranscriptSegmentIDs.contains(segment.id),
// The virtualized path is itself the progressive reveal. Its newest chunk
// is present on the first frame, without waiting for whole-list settling.
reveal: true,
reduceMotion: reduceMotion,
onMeasured: { height in
recordTranscriptSegmentHeight(
@@ -1816,45 +1853,34 @@ struct SessionDetailView: View {
session: store.openSessionID,
revertEpoch: session?.revertEpoch ?? 0,
segmentCount: projection.segments.count,
revealed: transcriptRevealed,
userScrolling: isUserScrollingTranscript))
{
await hydrateTranscriptSegments(projection.segments)
hydrateTranscriptSegments(projection.segments)
}
}
/// Mount one tail-first segment per main-actor slice. Only enough segments to cover the initial
/// viewport hydrate automatically; the native list keeps the older history as cheap reserved
/// slots and mounts one when a scroll reaches it. `TranscriptRenderSegmentSlot` owns the local
/// opacity fade, so this parent state change never animates list layout or the scroll anchor.
private func hydrateTranscriptSegments(_ segments: [TranscriptRenderSegment]) async {
guard let sessionID = store.openSessionID, !segments.isEmpty else { return }
/// Mount the entire visible tail in one non-animated state transaction. The native list keeps
/// older history as cheap reserved slots and mounts those immediately on demand, but there is
/// no artificial cadence between chunks that are already needed for the initial viewport.
private func hydrateTranscriptSegments(_ segments: [TranscriptRenderSegment]) {
guard store.openSessionID != nil, !segments.isEmpty, !isUserScrollingTranscript else {
return
}
let visibleTail = TranscriptRenderSegmenter.initialHydration(segments)
if let tail = visibleTail.first,
!mountedTranscriptSegmentIDs.contains(tail.id) {
mountTranscriptSegment(tail.id)
}
guard transcriptRevealed, !isUserScrollingTranscript else { return }
for segment in visibleTail {
guard !Task.isCancelled, store.openSessionID == sessionID else { return }
if !mountedTranscriptSegmentIDs.contains(segment.id) {
mountTranscriptSegment(segment.id)
}
// `Task.yield()` alone may immediately re-enqueue this task ahead of AppKit's pending
// layout/display work. One short real suspension guarantees a frame opportunity and
// bounds the cadence without making the upward fill feel deliberately slow.
try? await Task.sleep(for: .milliseconds(18))
}
mountTranscriptSegments(visibleTail.map(\.id))
}
private func mountTranscriptSegment(_ id: Int) {
guard !mountedTranscriptSegmentIDs.contains(id) else { return }
mountTranscriptSegments([id])
}
private func mountTranscriptSegments(_ ids: [Int]) {
let missing = ids.filter { !mountedTranscriptSegmentIDs.contains($0) }
guard !missing.isEmpty else { return }
var transaction = Transaction(animation: nil)
transaction.disablesAnimations = true
withTransaction(transaction) {
_ = mountedTranscriptSegmentIDs.insert(id)
mountedTranscriptSegmentIDs.formUnion(missing)
}
}
@@ -2781,17 +2807,11 @@ private struct TranscriptLoadingGlyphs: View {
/// correcting that value created a second layout pass. This local blur/opacity animation never
/// animates the parent `List`, row position, or bottom anchor.
private struct TranscriptRenderSegmentSlot<Content: View>: View {
private struct RevealToken: Hashable {
let mounted: Bool
let reveal: Bool
}
let seed: Int
let length: Int
let fontSize: CGFloat
let reservedHeight: CGFloat
let mounted: Bool
let reveal: Bool
let reduceMotion: Bool
let onMeasured: (CGFloat) -> Void
let onPriorityMount: () -> Void
@@ -2805,7 +2825,6 @@ private struct TranscriptRenderSegmentSlot<Content: View>: View {
fontSize: CGFloat,
reservedHeight: CGFloat,
mounted: Bool,
reveal: Bool,
reduceMotion: Bool,
onMeasured: @escaping (CGFloat) -> Void,
onPriorityMount: @escaping () -> Void,
@@ -2816,11 +2835,13 @@ private struct TranscriptRenderSegmentSlot<Content: View>: View {
self.fontSize = fontSize
self.reservedHeight = reservedHeight
self.mounted = mounted
self.reveal = reveal
self.reduceMotion = reduceMotion
self.onMeasured = onMeasured
self.onPriorityMount = onPriorityMount
self.content = content()
// The newest chunk is mounted before the list's first construction. Seed its local state
// from that fact so real text is present in frame one rather than waiting for `.task`.
_visible = State(initialValue: mounted)
}
var body: some View {
@@ -2866,21 +2887,13 @@ private struct TranscriptRenderSegmentSlot<Content: View>: View {
}
}
.onAppear(perform: onPriorityMount)
.task(id: RevealToken(mounted: mounted, reveal: reveal)) {
guard mounted, reveal else {
.task(id: mounted) {
guard mounted else {
visible = false
return
}
guard !reduceMotion else {
visible = true
return
}
visible = false
// Give the just-mounted transparent body one layout/measurement opportunity before
// starting its fade. The parent hydrator's tail-first cadence supplies the perceived
// bottom-to-top direction without moving any transcript element.
try? await Task.sleep(for: .milliseconds(12))
guard !Task.isCancelled else { return }
// Mounting already happens after the row exists. Begin the focus transition in this
// same lifecycle turnthere is no extra sleep between a needed chunk and its text.
visible = true
}
}
+6 -4
View File
@@ -40,10 +40,12 @@
snapshot→history pull reaches the controller's known tail. Unread-marker writes, Nash viewer
hydration, and whole-history tool-summary bookkeeping follow that readiness edge instead of
withholding the canvas. Continuous scroll geometry is quantized into three threshold regions
before it reaches the main actor. Long histories build their newest six-row body into the first
visible frame, then hydrate only the next two tail segments newest-to-oldest with an 18 ms
suspension between mounts; older rows remain cheap until the native list reaches them. Every
segment has a cheap row-count `length` and an immediate transparent slot. A derived, per-session
before it reaches the main actor. Long histories mount the three-segment visible tail in one
non-animated state transaction; older rows remain cheap until the native list reaches them, then
mount immediately on demand. After the first real tail chunk exists, an explicit unanimated pin
targets the fixed sentinel below all prose, autoship/lock/status rows, and bottom padding rather
than relying on `List`'s early estimated bottom. Every 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 bounded deterministic glyph groups (also