Scroll Position Adjustment

Nucleic-Session: 4975C92B-B473-4ABC-97F0-12F41AC8BB87
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-06 00:32:51 -07:00
co-authored by Nucleic
parent 8bbf3bf5c1
commit 9246ef0d43
@@ -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,12 +434,99 @@ 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")
}
}