diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 33e4355..08f9559 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -388,13 +388,36 @@ struct SessionDetailView: View { struct TranscriptList: View { let events: [AgentEvent] + /// Bumped by the parent when the user sends a message — a deliberate "show me what happens + /// next" that jumps to the bottom even if they'd scrolled up to read history. + var scrollToBottomRequest: Int = 0 @AppStorage("nucleic.showRawEvents") private var showRaw = false @AppStorage("nucleic.showLockEvents") private var showLockEvents = true + /// 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 + + /// 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 + /// readings, so the settling layout can't strand the chat above its true bottom or flash the + /// chevron; live scrolls are honored once it clears. Mirrors the Mac's `transcriptSettling`. + @State private var transcriptSettling = true + + /// Total scroll-content height, tracked live from the scroll view's geometry, but only while + /// settling — a `.task(id:)` debounces it to detect when the opening layout has come to rest. + @State private var transcriptContentHeight: CGFloat = 0 + private var items: [TranscriptItem] { TranscriptProjection.build(events, showRaw: showRaw, showLockEvents: showLockEvents) } + /// How much content may still sit below the viewport's bottom edge and still count as "at + /// the bottom" — a little slack so streaming deltas and rounding never flip the gate. Mirrors + /// the Mac's `bottomFollowThreshold`. + private let bottomFollowThreshold: CGFloat = 24 + var body: some View { ScrollViewReader { proxy in ScrollView { @@ -411,13 +434,100 @@ struct TranscriptList: View { // A drag on the transcript dismisses the keyboard, so a tall multiline composer can // be put away without leaving the session. .scrollDismissesKeyboard(.interactively) + // The single-argument `defaultScrollAnchor(.bottom)` governs both the initial offset + // and how the scroll view re-pins on content-size changes — it sticks the bottom edge + // in view as the transcript grows. That native stickiness is what we want while the + // user is parked at the bottom (the chat opens at the end and follows live output), + // but it's also what yanked them back down whenever anything changed while they'd + // scrolled up to read history. So gate the anchor itself: drop it to `nil` the moment + // 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) + // 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 + } + } + // Detect when the opening layout has come to rest: track the content height while + // settling and let `.task(id:)` debounce it — the task restarts on every change and + // only reaches its body once the height has held steady. Freezing the tracker after + // settling keeps live streaming (which changes the height every frame) from re-arming. + .onScrollGeometryChange(for: CGFloat.self) { $0.contentSize.height.rounded() } action: { _, height in + if transcriptSettling { transcriptContentHeight = height } + } + .task(id: transcriptContentHeight) { + guard transcriptSettling, transcriptContentHeight > 0 else { return } + try? await Task.sleep(for: .milliseconds(80)) + if Task.isCancelled { return } + transcriptSettling = false + } // 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. + // 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 let last = items.last { withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } } + 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) } } } + + /// Scroll the last message to the bottom edge; `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 } + if animated { + withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } + } else { + proxy.scrollTo(last.id, anchor: .bottom) + } + } +} + +/// The floating "jump to the latest" chevron shown above the chat bar while the user has +/// scrolled up away from the bottom of the transcript. The mobile twin of the Mac's +/// `JumpToBottomButton`. +private struct JumpToBottomButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "chevron.down") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Palette.accent) + .frame(width: 30, height: 30) + .background(.regularMaterial, in: .circle) + .overlay(Circle().strokeBorder(.separator, lineWidth: 0.5)) + .shadow(color: .black.opacity(0.18), radius: 4, y: 1) + } + .buttonStyle(.plain) + .accessibilityLabel("Jump to the latest message") + } } /// The diff sheet's content, read-only over the on-demand `WireSessionDiff`. On a phone it's a per-file