Scroll Position Adjustment
Nucleic-Session: 4975C92B-B473-4ABC-97F0-12F41AC8BB87 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -388,13 +388,36 @@ struct SessionDetailView: View {
|
|||||||
|
|
||||||
struct TranscriptList: View {
|
struct TranscriptList: View {
|
||||||
let events: [AgentEvent]
|
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.showRawEvents") private var showRaw = false
|
||||||
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true
|
@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] {
|
private var items: [TranscriptItem] {
|
||||||
TranscriptProjection.build(events, showRaw: showRaw, showLockEvents: showLockEvents)
|
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 {
|
var body: some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -411,12 +434,99 @@ struct TranscriptList: View {
|
|||||||
// A drag on the transcript dismisses the keyboard, so a tall multiline composer can
|
// A drag on the transcript dismisses the keyboard, so a tall multiline composer can
|
||||||
// be put away without leaving the session.
|
// be put away without leaving the session.
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.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
|
// 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) {
|
.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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user