ios: let native scrolling handle Agent keyboard resizing
TestFlight / Build and upload (push) Successful in 1m49s

This commit is contained in:
2026-10-16 12:37:04 -07:00
parent d5427926d5
commit e681e9e05c
2 changed files with 140 additions and 116 deletions
@@ -18,20 +18,20 @@ struct SybilAgentView: View {
} }
// Draft edits and keyboard focus belong to the composer. The transcript observes // Draft edits and keyboard focus belong to the composer. The transcript observes
// only room/render/scroll state; resizing is handled by its scroll geometry. // only room/render/scroll state; SwiftUI handles keyboard avoidance.
private struct AgentTranscriptView: View { private struct AgentTranscriptView: View {
var room: SybilAgentRoom var room: SybilAgentRoom
var topInset: CGFloat var topInset: CGFloat
var latestRequest: Int var latestRequest: Int
@Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var atLatest = true @State private var atLatest = true
@State private var isScrolling = false @State private var scrollPhase: ScrollPhase = .idle
@State private var historyLimit = 100 @State private var historyLimit = 100
@State private var historyStartID: String? @State private var historyStartID: String?
@State private var scrollPosition = ScrollPosition() @State private var scrollPosition = ScrollPosition()
@State private var scrollTracking = AgentScrollTracking() @State private var scrollTracking = AgentScrollTracking()
@State private var appeared = false @State private var appeared = false
private let bottomID = "agent-bottom" private var isScrolling: Bool { scrollPhase.isScrolling }
private var visibleReadingAnchor: AgentReadingAnchor? { private var visibleReadingAnchor: AgentReadingAnchor? {
scrollTracking.rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollTracking.metrics.height } scrollTracking.rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollTracking.metrics.height }
@@ -48,7 +48,6 @@ private struct AgentTranscriptView: View {
let allMessages = transcript.messages let allMessages = transcript.messages
let startIndex = transcript.startIndex let startIndex = transcript.startIndex
let displayed = transcript.displayed let displayed = transcript.displayed
ScrollViewReader { proxy in
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 15) { LazyVStack(alignment: .leading, spacing: 15) {
if allMessages.count > displayed.count { if allMessages.count > displayed.count {
@@ -80,7 +79,7 @@ private struct AgentTranscriptView: View {
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
.accessibilityIdentifier("agent-typing") .accessibilityIdentifier("agent-typing")
} }
Color.clear.frame(height: 1).id(bottomID) Color.clear.frame(height: 1)
} }
.scrollTargetLayout() .scrollTargetLayout()
.padding(.horizontal, 16) .padding(.horizontal, 16)
@@ -95,10 +94,13 @@ private struct AgentTranscriptView: View {
.coordinateSpace(name: "agent-scroll") .coordinateSpace(name: "agent-scroll")
.scrollPosition($scrollPosition) .scrollPosition($scrollPosition)
.defaultScrollAnchor(.bottom, for: .initialOffset) .defaultScrollAnchor(.bottom, for: .initialOffset)
// Let SwiftUI follow size changes, including keyboard avoidance,
// without competing scrollTo calls from geometry updates.
.defaultScrollAnchor(atLatest ? .bottom : nil, for: .sizeChanges)
.defaultScrollAnchor(.bottom, for: .alignment) .defaultScrollAnchor(.bottom, for: .alignment)
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.onScrollPhaseChange { _, phase in .onScrollPhaseChange { _, phase in
isScrolling = phase.isScrolling scrollPhase = phase
} }
.onScrollGeometryChange(for: AgentScrollMetrics.self) { geometry in .onScrollGeometryChange(for: AgentScrollMetrics.self) { geometry in
AgentScrollMetrics(offset: geometry.contentOffset.y, AgentScrollMetrics(offset: geometry.contentOffset.y,
@@ -106,16 +108,16 @@ private struct AgentTranscriptView: View {
contentHeight: geometry.contentSize.height, contentHeight: geometry.contentSize.height,
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top, bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top,
visibleBottom: geometry.visibleRect.maxY) visibleBottom: geometry.visibleRect.maxY)
} action: { previous, next in } action: { _, next in
#if DEBUG #if DEBUG
AgentRenderDiagnostics.record("scrollGeometry") AgentRenderDiagnostics.record("scrollGeometry")
#endif #endif
scrollTracking.metrics = next scrollTracking.metrics = next
if scrollTracking.atBottom != next.atBottom { scrollTracking.atBottom = next.atBottom } if scrollTracking.atBottom != next.atBottom { scrollTracking.atBottom = next.atBottom }
if isScrolling || next.contentHeight <= next.height { atLatest = next.atBottom } // Only user scrolling changes follow intent. Animated Latest
if atLatest, !isScrolling, // navigation and native keyboard resizing must not cancel it.
previous.height != next.height || previous.contentHeight != next.contentHeight || previous.bottomInset != next.bottomInset { if (isScrolling && scrollPhase != .animating) || next.contentHeight <= next.height {
proxy.scrollTo(bottomID, anchor: .bottom) atLatest = next.atBottom
} }
} }
.onPreferenceChange(AgentRowFrames.self) { frames in .onPreferenceChange(AgentRowFrames.self) { frames in
@@ -144,7 +146,7 @@ private struct AgentTranscriptView: View {
if !atLatest { if !atLatest {
Button { Button {
atLatest = true atLatest = true
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(bottomID, anchor: .bottom) } withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { scrollPosition.scrollTo(edge: .bottom) }
} label: { } label: {
Label(room.unreadCount > 0 ? "\(room.unreadCount) unread" : "Latest", systemImage: "arrow.down") Label(room.unreadCount > 0 ? "\(room.unreadCount) unread" : "Latest", systemImage: "arrow.down")
.font(.sybil(.caption, weight: .semibold)) .font(.sybil(.caption, weight: .semibold))
@@ -164,12 +166,11 @@ private struct AgentTranscriptView: View {
if !loading && historyStartID == nil { historyStartID = displayed.first?.id } if !loading && historyStartID == nil { historyStartID = displayed.first?.id }
} }
.onChange(of: allMessages.count) { _, _ in .onChange(of: allMessages.count) { _, _ in
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) } if atLatest { scrollPosition.scrollTo(edge: .bottom) }
} }
.onChange(of: latestRequest) { _, _ in .onChange(of: latestRequest) { _, _ in
atLatest = true atLatest = true
proxy.scrollTo(bottomID, anchor: .bottom) scrollPosition.scrollTo(edge: .bottom)
}
} }
.modifier(AgentReadAcknowledgement(room: room, tracking: scrollTracking, .modifier(AgentReadAcknowledgement(room: room, tracking: scrollTracking,
atLatest: atLatest, isScrolling: isScrolling)) atLatest: atLatest, isScrolling: isScrolling))
@@ -123,6 +123,7 @@ final class TranscriptFadeUITests: XCTestCase {
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest) let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest)
await fulfillment(of: [followed], timeout: 5) await fulfillment(of: [followed], timeout: 5)
composer.tap() composer.tap()
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
XCTAssertTrue(latest.isHittable) XCTAssertTrue(latest.isHittable)
XCTAssertTrue(app.buttons["agent-attach"].isHittable) XCTAssertTrue(app.buttons["agent-attach"].isHittable)
capture("agent-fade-latest-with-keyboard") capture("agent-fade-latest-with-keyboard")
@@ -133,5 +134,27 @@ final class TranscriptFadeUITests: XCTestCase {
try await Task.sleep(for: .milliseconds(500)) try await Task.sleep(for: .milliseconds(500))
let read = try await control(["action": "state"]) let read = try await control(["action": "state"])
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int) XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
// A reply still follows and settles as read while the keyboard is up.
try await control(["action": "reply", "content": "Reply with keyboard visible"])
let keyboardReply = app.staticTexts["Reply with keyboard visible"]
let keyboardFollowed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: keyboardReply)
await fulfillment(of: [keyboardFollowed], timeout: 5)
try await Task.sleep(for: .milliseconds(500))
let keyboardRead = try await control(["action": "state"])
XCTAssertEqual(keyboardRead["readCursor"] as? Int, keyboardRead["cursor"] as? Int)
// Interactive dismissal is user navigation; Latest must still return to
// the bottom, and presenting the keyboard again must keep it visible.
let dismissStart = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9))
let dismissEnd = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98))
dismissStart.press(forDuration: 0.1, thenDragTo: dismissEnd)
let dismissed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: app.keyboards.firstMatch)
await fulfillment(of: [dismissed], timeout: 5)
if app.buttons["agent-jump-latest"].exists { app.buttons["agent-jump-latest"].tap() }
XCTAssertTrue(keyboardReply.isHittable)
composer.tap()
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
XCTAssertTrue(keyboardReply.isHittable)
} }
} }