diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index fba176f..33e7a30 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -284,15 +284,6 @@ struct SessionDetailView: View { /// pending. Content scrolls beneath it; nothing renders when there's nothing to act on. private var actionArea: some View { VStack(spacing: 8) { - // The jump-to-bottom chevron rides here, immediately above the chat bar — not as a - // transcript overlay, which aligned to the scroll view's full-height bounds and so sat - // behind this floating bar at the screen's bottom edge. Shown only while scrolled up; a - // tap bumps the same scroll request the send button uses, so following resumes once the - // transcript reaches the bottom. Mirrors the Mac's `JumpToBottomButton`. - if !isScrolledToBottom { - JumpToBottomButton { scrollToBottomRequest += 1 } - .transition(.move(edge: .bottom).combined(with: .opacity)) - } // Interacting with a chat while the owning Mac is unreachable surfaces this first, so a // disabled composer reads as "offline / read-only history" rather than broken. if !store.connectivity.isLive { disconnectedBanner } @@ -300,6 +291,27 @@ struct SessionDetailView: View { } .padding(.horizontal, 12) .padding(.bottom, 8) + // The jump-to-bottom chevron floats just above the chat bar as an *overlay* — deliberately + // not a stack member. This whole action area is the transcript's bottom `safeAreaInset`, + // so a stack-member chevron changed the scroll view's bottom inset by ~38pt every time it + // appeared — and its visibility is *decided by* that same scroll geometry + // (`isScrolledToBottom`). That geometry→chevron→geometry cycle could oscillate every + // frame near the follow threshold, re-layouting the whole eager transcript each time and + // pinning the main thread at 100% with the UI locked. An overlay contributes nothing to + // the inset height, so showing or hiding it can't move the scroll geometry at all. + // (It's not a transcript overlay either — that aligned to the scroll view's full-height + // bounds and sat behind this floating bar at the screen's bottom edge.) A tap bumps the + // same scroll request the send button uses, so following resumes once the transcript + // reaches the bottom. Mirrors the Mac's `JumpToBottomButton`. + .overlay(alignment: .top) { + if !isScrolledToBottom { + JumpToBottomButton { scrollToBottomRequest += 1 } + // Hang the button *above* the bar: report its bottom edge (plus a gap) as its + // top guide, so the overlay's top alignment places it outside the bar's bounds. + .alignmentGuide(.top) { $0[.bottom] + 8 } + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } .animation(.easeInOut(duration: 0.15), value: isScrolledToBottom) } @@ -576,6 +588,26 @@ struct TranscriptList: View { /// the Mac's `bottomFollowThreshold`. private let bottomFollowThreshold: CGFloat = 24 + /// How far a *user* scroll must move away from the bottom before following disengages. Wider + /// than the re-engage threshold above on purpose (hysteresis): the action bar's height isn't + /// constant (composer lines grow, the working row appears, the keyboard dismisses + /// interactively), and every inset change perturbs the scroll geometry by tens of points. A + /// single threshold read both ways let one such perturbation flip the gate, whose reactions + /// (anchor toggle, bar animation) perturbed the geometry again — an oscillation that + /// re-layouted the whole eager transcript every frame and pinned the main thread at 100%. + /// The band is wider than any bar-height delta, so only a deliberate scroll crosses it. + private let bottomUnfollowThreshold: CGFloat = 64 + + /// The live scroll phase, used to tell *user* scrolling (finger down / flick decelerating) + /// from programmatic motion (autoscroll animations, anchor re-pins, inset changes). Only a + /// user-driven phase may disengage bottom-following — a programmatic perturbation can only + /// ever re-engage it — which structurally breaks every geometry→state→geometry feedback + /// cycle: no chain of layout reactions can take the gate false and sustain itself. + @State private var scrollPhase: ScrollPhase = .idle + private var isUserScrolling: Bool { + scrollPhase == .tracking || scrollPhase == .interacting || scrollPhase == .decelerating + } + var body: some View { ScrollViewReader { proxy in ScrollView { @@ -613,22 +645,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 @@ -657,9 +695,15 @@ struct TranscriptList: View { } // Coalescing means item count lags event count; key the autoscroll on the raw stream // so every streamed delta keeps the view pinned to the bottom — but only while the - // user is already parked there. Scrolling up to read history is never yanked down. + // user is already parked there. Scrolling up to read history is never yanked down, + // and a finger actively on the transcript is never fought mid-drag (the drag that + // takes them past the unfollow threshold flips the gate; until then the native + // bottom anchor alone keeps content pinned, without an animated scroll grabbing the + // viewport back out of their hand). .onChange(of: events.count) { - if isScrolledToBottom { scrollToEnd(proxy, animated: !transcriptSettling) } + if isScrolledToBottom, !isUserScrolling { + scrollToEnd(proxy, animated: !transcriptSettling) + } } // An explicit jump — the chevron or sending a message — always wins. The chevron // itself lives in the parent's chat bar (above the composer), not as an overlay here,