Transcript Incremental Projection

Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-06 19:00:22 -07:00
co-authored by Nucleic
parent 9c13f2dd48
commit cbd828c761
@@ -633,22 +633,28 @@ struct TranscriptList: View {
// they scroll away, which lets new content land off-screen below instead of dragging // they scroll away, which lets new content land off-screen below instead of dragging
// the viewport, and restore `.bottom` once they're back at the end. // the viewport, and restore `.bottom` once they're back at the end.
.defaultScrollAnchor(isScrolledToBottom ? .bottom : nil) .defaultScrollAnchor(isScrolledToBottom ? .bottom : nil)
.onScrollPhaseChange { _, newPhase in scrollPhase = newPhase }
// Track the live scroll position straight from the scroll view's geometry: how much // Track the live scroll position straight from the scroll view's geometry: how much
// content still sits below the viewport bottom. Within the slack threshold means the // content still sits below the viewport bottom. Parked within the follow threshold
// user is parked at the end (live output keeps following); scrolling up flips this // means live output keeps following; a *user* scroll past the (wider) unfollow
// false, which drops the anchor (above) and reveals the jump-to-bottom chevron. // threshold flips it false, which drops the anchor (above) and reveals the chevron.
.onScrollGeometryChange(for: Bool.self) { geo in // Asymmetric on purpose — see `bottomUnfollowThreshold` / `isUserScrolling`: a
geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y // programmatic geometry change (anchor re-pin, bar resize, keyboard, autoscroll
<= bottomFollowThreshold // animation) may re-engage following but can never disengage it, so no layout
} action: { _, atBottom in // feedback cycle through this gate can sustain itself. Tracking the rounded distance
// While the chat is opening the layout grows over a few passes and the scroll // (not a Bool) also means every scroll emits fresh values, so the gate can't latch
// offset lags each growth by a frame — sampling that frame reads "not at bottom" // against a stale reading (the old settle-window latch bug).
// even though `.defaultScrollAnchor(.bottom)` is about to re-pin. So during the .onScrollGeometryChange(for: CGFloat.self) { geo in
// open window accept only "at bottom" readings; honor real scroll-ups after. (geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y).rounded()
if transcriptSettling { } action: { _, distance in
if atBottom { isScrolledToBottom = true } if distance <= bottomFollowThreshold {
} else { isScrolledToBottom = true
isScrolledToBottom = atBottom } else if !transcriptSettling, distance > bottomUnfollowThreshold, isUserScrolling {
// While the chat is opening the layout grows over a few passes and the offset
// lags each growth by a frame — those frames read "not at bottom" even though
// `.defaultScrollAnchor(.bottom)` is about to re-pin, so settling accepts
// only re-engagement (the `!transcriptSettling` above).
isScrolledToBottom = false
} }
} }
// Detect when the opening layout has come to rest: track the content height while // Detect when the opening layout has come to rest: track the content height while