nvrsion: Add: Fix auto-scroll instability in iOS session chat so the transcript no longer jitters on scroll, and begin MACOS_VM_NATIVE_AGENT_Markdown in ./docs/MACOS_VM_NATIVE_AGENT.md.

Nucleic-Promote: 1
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-06 04:01:31 -07:00
co-authored by Nucleic
parent 6292c7e723
commit 9305e9670e
@@ -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 // 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). // read history — sending is a deliberate "show me what happens next" (mirrors the Mac).
@State private var scrollToBottomRequest = 0 @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? { private var summary: WireSessionSummary? {
store.sessions.first { $0.sessionID == sessionID } store.sessions.first { $0.sessionID == sessionID }
} }
var body: some View { 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 // 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. // in a boxed strip below it, so the transcript runs the full height of the screen.
.safeAreaInset(edge: .bottom) { actionArea } .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. /// pending. Content scrolls beneath it; nothing renders when there's nothing to act on.
private var actionArea: some View { private var actionArea: some View {
VStack(spacing: 8) { 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 // 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. // disabled composer reads as "offline / read-only history" rather than broken.
if !store.connectivity.isLive { disconnectedBanner } if !store.connectivity.isLive { disconnectedBanner }
@@ -243,6 +257,7 @@ struct SessionDetailView: View {
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.bottom, 8) .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 /// 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 /// 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 /// 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. /// chevron appears. Starts true — a freshly opened chat is anchored at the bottom. Owned by
@State private var isScrolledToBottom = true /// 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 /// 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 /// 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 { var body: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView { 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 ForEach(items) { item in
TranscriptRow(item: item).id(item.id) 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() .padding()
// Cap the transcript to a readable measure on a wide iPad so lines don't run // 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 // 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. // user is already parked there. Scrolling up to read history is never yanked down.
.onChange(of: events.count) { .onChange(of: events.count) {
if isScrolledToBottom { scrollToLast(proxy, animated: !transcriptSettling) } if isScrolledToBottom { scrollToEnd(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)
} }
// 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. /// 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) { private func scrollToEnd(_ proxy: ScrollViewProxy, animated: Bool = true) {
guard let last = items.last else { return }
if animated { if animated {
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) }
} else { } 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 /// The floating "jump to the latest" chevron shown above the chat bar while the user has