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
// the viewport, and restore `.bottom` once they're back at the end.
.defaultScrollAnchor(isScrolledToBottom ? .bottom : nil)
.onScrollPhaseChange { _, newPhase in scrollPhase = newPhase }
// 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
// user is parked at the end (live output keeps following); scrolling up flips this
// false, which drops the anchor (above) and reveals the jump-to-bottom chevron.
.onScrollGeometryChange(for: Bool.self) { geo in
geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y
<= bottomFollowThreshold
} action: { _, atBottom in
// While the chat is opening the layout grows over a few passes and the scroll
// offset lags each growth by a frame — sampling that frame reads "not at bottom"
// even though `.defaultScrollAnchor(.bottom)` is about to re-pin. So during the
// open window accept only "at bottom" readings; honor real scroll-ups after.
if transcriptSettling {
if atBottom { isScrolledToBottom = true }
} else {
isScrolledToBottom = atBottom
// content still sits below the viewport bottom. Parked within the follow threshold
// means live output keeps following; a *user* scroll past the (wider) unfollow
// threshold flips it false, which drops the anchor (above) and reveals the chevron.
// Asymmetric on purpose — see `bottomUnfollowThreshold` / `isUserScrolling`: a
// programmatic geometry change (anchor re-pin, bar resize, keyboard, autoscroll
// animation) may re-engage following but can never disengage it, so no layout
// feedback cycle through this gate can sustain itself. Tracking the rounded distance
// (not a Bool) also means every scroll emits fresh values, so the gate can't latch
// against a stale reading (the old settle-window latch bug).
.onScrollGeometryChange(for: CGFloat.self) { geo in
(geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y).rounded()
} action: { _, distance in
if distance <= bottomFollowThreshold {
isScrolledToBottom = true
} 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