diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 75c1d5d..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) }