diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index fba176f..4947663 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -576,6 +576,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 {