Merge nucleic/upbeat-maple-ferret-jov0 into dev
This commit is contained in:
@@ -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 lines—not 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 turn—there is no extra sleep between a needed chunk and its text.
|
||||
visible = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user