From cbd828c761cf6b98327bb7f01c55fada407eeec6 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:00:22 -0700 Subject: [PATCH] Transcript Incremental Projection Nucleic-Session: 0FFD007B-0696-4517-9429-129C7B0FD5AC Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 4947663..c7f0f63 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -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