diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 2b0ef81..60bac70 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -18,13 +18,18 @@ struct SessionDetailView: View { // Bumped on send to jump the transcript to the bottom, even if the user had scrolled up to // read history — sending is a deliberate "show me what happens next" (mirrors the Mac). @State private var scrollToBottomRequest = 0 + // Owned here (not inside `TranscriptList`) so the jump-to-bottom chevron can ride in the chat + // bar just above the composer. `TranscriptList` drives it from the scroll geometry. + @State private var isScrolledToBottom = true private var summary: WireSessionSummary? { store.sessions.first { $0.sessionID == sessionID } } var body: some View { - TranscriptList(events: store.openEvents, scrollToBottomRequest: scrollToBottomRequest) + TranscriptList(events: store.openEvents, + scrollToBottomRequest: scrollToBottomRequest, + isScrolledToBottom: $isScrolledToBottom) // The chat bar floats over the scrolling content on Liquid Glass instead of sitting // in a boxed strip below it, so the transcript runs the full height of the screen. .safeAreaInset(edge: .bottom) { actionArea } @@ -236,6 +241,15 @@ 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 } @@ -243,6 +257,7 @@ struct SessionDetailView: View { } .padding(.horizontal, 12) .padding(.bottom, 8) + .animation(.easeInOut(duration: 0.15), value: isScrolledToBottom) } /// The offline notice over the transcript: the chat's history is cached and readable, but the @@ -401,8 +416,9 @@ struct TranscriptList: View { /// True while the user is parked at (within a hair of) the bottom, so live output keeps /// following; once they scroll up it flips false, following stops, and the jump-to-bottom - /// chevron appears. Starts true — a freshly opened chat is anchored at the bottom. - @State private var isScrolledToBottom = true + /// chevron appears. Starts true — a freshly opened chat is anchored at the bottom. Owned by + /// the parent so the chevron can live in the chat bar above the composer. + @Binding var isScrolledToBottom: Bool /// True for a short window right after the transcript appears, while it runs its first /// layout passes and its events fill in. During it we accept only "at bottom" scroll @@ -426,10 +442,22 @@ struct TranscriptList: View { var body: some View { ScrollViewReader { proxy in ScrollView { - LazyVStack(alignment: .leading, spacing: 14) { + // Eager VStack (not Lazy): the whole transcript is in memory, and eager layout + // hands the ScrollView its true, *fixed* total content height up front. A + // LazyVStack instead realizes rows as they scroll into view, so its reported + // content height shifts mid-scroll as rows resolve — and that shifting height fed + // straight back through the geometry reader below (flip at-bottom → toggle the + // `.defaultScrollAnchor` → re-pin → new height → …), which is what made the whole + // transcript jitter up and down under a drag. A stable height breaks the loop. + // Matches the Mac transcript, for the same reason. + VStack(alignment: .leading, spacing: 14) { ForEach(items) { item in TranscriptRow(item: item).id(item.id) } + // Fixed, zero-content scroll target for "jump to bottom". Scrolling to the last + // row's id instead would chase a moving target — the final row's identity + // changes as its streaming deltas coalesce — nudging the viewport mid-stream. + Color.clear.frame(height: 1).id(Self.bottomAnchorID) } .padding() // Cap the transcript to a readable measure on a wide iPad so lines don't run @@ -483,35 +511,27 @@ struct TranscriptList: View { // 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. .onChange(of: events.count) { - if isScrolledToBottom { scrollToLast(proxy, animated: !transcriptSettling) } - } - // An explicit jump — the chevron or sending a message — always wins. - .onChange(of: scrollToBottomRequest) { scrollToLast(proxy) } - // Floating chevron above the chat bar, shown only while scrolled up; tap to jump back - // to the latest output. Mirrors the Mac's `JumpToBottomButton`. - .overlay(alignment: .bottom) { - ZStack { - if !isScrolledToBottom { - JumpToBottomButton { scrollToLast(proxy) } - .padding(.bottom, 12) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - } - .animation(.easeInOut(duration: 0.15), value: isScrolledToBottom) + if isScrolledToBottom { 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, + // so it sits over the composer instead of behind the floating bar; a tap bumps this + // same request, and following resumes once the geometry reader sees the bottom. + .onChange(of: scrollToBottomRequest) { scrollToEnd(proxy) } } } - /// Scroll the last message to the bottom edge; `animated: false` during the open-settle + /// Scroll the fixed bottom anchor into view; `animated: false` during the open-settle /// window so a chat that's still laying out snaps to the tail instead of easing into place. - private func scrollToLast(_ proxy: ScrollViewProxy, animated: Bool = true) { - guard let last = items.last else { return } + private func scrollToEnd(_ proxy: ScrollViewProxy, animated: Bool = true) { if animated { - withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } + withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } } else { - proxy.scrollTo(last.id, anchor: .bottom) + proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } } + + private static let bottomAnchorID = "nucleic.transcript-bottom" } /// The floating "jump to the latest" chevron shown above the chat bar while the user has