ios: let native scrolling handle Agent keyboard resizing
TestFlight / Build and upload (push) Successful in 1m49s
TestFlight / Build and upload (push) Successful in 1m49s
This commit is contained in:
@@ -18,20 +18,20 @@ struct SybilAgentView: View {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
var room: SybilAgentRoom
|
||||
var topInset: CGFloat
|
||||
var latestRequest: Int
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var atLatest = true
|
||||
@State private var isScrolling = false
|
||||
@State private var scrollPhase: ScrollPhase = .idle
|
||||
@State private var historyLimit = 100
|
||||
@State private var historyStartID: String?
|
||||
@State private var scrollPosition = ScrollPosition()
|
||||
@State private var scrollTracking = AgentScrollTracking()
|
||||
@State private var appeared = false
|
||||
private let bottomID = "agent-bottom"
|
||||
private var isScrolling: Bool { scrollPhase.isScrolling }
|
||||
|
||||
private var visibleReadingAnchor: AgentReadingAnchor? {
|
||||
scrollTracking.rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollTracking.metrics.height }
|
||||
@@ -48,129 +48,130 @@ private struct AgentTranscriptView: View {
|
||||
let allMessages = transcript.messages
|
||||
let startIndex = transcript.startIndex
|
||||
let displayed = transcript.displayed
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 15) {
|
||||
if allMessages.count > displayed.count {
|
||||
Button("Load earlier messages") {
|
||||
// Loading older history may move the viewport as lazy
|
||||
// row estimates settle; do not chase the old position.
|
||||
scrollTracking.rowFrames = [:]
|
||||
atLatest = false
|
||||
historyLimit += 100
|
||||
historyStartID = allMessages[max(0, startIndex - 100)].id
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 15) {
|
||||
if allMessages.count > displayed.count {
|
||||
Button("Load earlier messages") {
|
||||
// Loading older history may move the viewport as lazy
|
||||
// row estimates settle; do not chase the old position.
|
||||
scrollTracking.rowFrames = [:]
|
||||
atLatest = false
|
||||
historyLimit += 100
|
||||
historyStartID = allMessages[max(0, startIndex - 100)].id
|
||||
}
|
||||
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
|
||||
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in
|
||||
AgentTranscriptRow(message: message, room: room, tracksReadingAnchor: !atLatest,
|
||||
startsDay: index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt))
|
||||
.equatable()
|
||||
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
|
||||
.id(message.id)
|
||||
}
|
||||
if rendered.isTyping {
|
||||
HStack(spacing: 9) {
|
||||
AgentAvatar()
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Sybil is thinking…").font(.sybil(.caption))
|
||||
}
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.accessibilityIdentifier("agent-typing")
|
||||
}
|
||||
Color.clear.frame(height: 1).id(bottomID)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 16)
|
||||
// Match normal chat: content clears the header, while the
|
||||
// viewport extends beneath the shared navigation fade.
|
||||
.padding(.top, 16 + topInset)
|
||||
.frame(maxWidth: SybilLayout.webContentMaxWidth)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.accessibilityIdentifier("agent-transcript")
|
||||
.coordinateSpace(name: "agent-scroll")
|
||||
.scrollPosition($scrollPosition)
|
||||
.defaultScrollAnchor(.bottom, for: .initialOffset)
|
||||
.defaultScrollAnchor(.bottom, for: .alignment)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onScrollPhaseChange { _, phase in
|
||||
isScrolling = phase.isScrolling
|
||||
}
|
||||
.onScrollGeometryChange(for: AgentScrollMetrics.self) { geometry in
|
||||
AgentScrollMetrics(offset: geometry.contentOffset.y,
|
||||
height: geometry.containerSize.height,
|
||||
contentHeight: geometry.contentSize.height,
|
||||
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top,
|
||||
visibleBottom: geometry.visibleRect.maxY)
|
||||
} action: { previous, next in
|
||||
#if DEBUG
|
||||
AgentRenderDiagnostics.record("scrollGeometry")
|
||||
#endif
|
||||
scrollTracking.metrics = next
|
||||
if scrollTracking.atBottom != next.atBottom { scrollTracking.atBottom = next.atBottom }
|
||||
if isScrolling || next.contentHeight <= next.height { atLatest = next.atBottom }
|
||||
if atLatest, !isScrolling,
|
||||
previous.height != next.height || previous.contentHeight != next.contentHeight || previous.bottomInset != next.bottomInset {
|
||||
proxy.scrollTo(bottomID, anchor: .bottom)
|
||||
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
|
||||
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in
|
||||
AgentTranscriptRow(message: message, room: room, tracksReadingAnchor: !atLatest,
|
||||
startsDay: index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt))
|
||||
.equatable()
|
||||
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
|
||||
.id(message.id)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(AgentRowFrames.self) { frames in
|
||||
#if DEBUG
|
||||
AgentRenderDiagnostics.record("rowPreferences")
|
||||
#endif
|
||||
let anchor = !atLatest && !isScrolling ? visibleReadingAnchor : nil
|
||||
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
|
||||
// Preserve ordinary reading position after new messages or
|
||||
// text reflow. Normalize the saved frames to
|
||||
// the requested offset so the next layout cannot undo it.
|
||||
let requested = scrollTracking.metrics.offset + frame.minY - anchor.offset
|
||||
let target = min(max(requested, -scrollTracking.metrics.topInset), scrollTracking.metrics.maximumOffset)
|
||||
let appliedShift = target - scrollTracking.metrics.offset
|
||||
scrollTracking.rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
|
||||
if abs(appliedShift) > 1 {
|
||||
// Geometry reports the raw offset; ScrollPosition uses
|
||||
// zero at the top inset, including the safe area.
|
||||
scrollPosition.scrollTo(y: target + scrollTracking.metrics.topInset)
|
||||
if rendered.isTyping {
|
||||
HStack(spacing: 9) {
|
||||
AgentAvatar()
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Sybil is thinking…").font(.sybil(.caption))
|
||||
}
|
||||
} else {
|
||||
scrollTracking.rowFrames = frames
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.accessibilityIdentifier("agent-typing")
|
||||
}
|
||||
Color.clear.frame(height: 1)
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if !atLatest {
|
||||
Button {
|
||||
atLatest = true
|
||||
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(bottomID, anchor: .bottom) }
|
||||
} label: {
|
||||
Label(room.unreadCount > 0 ? "\(room.unreadCount) unread" : "Latest", systemImage: "arrow.down")
|
||||
.font(.sybil(.caption, weight: .semibold))
|
||||
.padding(12)
|
||||
.background(SybilTheme.surfaceStrong, in: Capsule())
|
||||
.overlay(Capsule().stroke(SybilTheme.border))
|
||||
}
|
||||
.padding(14)
|
||||
.accessibilityIdentifier("agent-jump-latest")
|
||||
.scrollTargetLayout()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 16)
|
||||
// Match normal chat: content clears the header, while the
|
||||
// viewport extends beneath the shared navigation fade.
|
||||
.padding(.top, 16 + topInset)
|
||||
.frame(maxWidth: SybilLayout.webContentMaxWidth)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.accessibilityIdentifier("agent-transcript")
|
||||
.coordinateSpace(name: "agent-scroll")
|
||||
.scrollPosition($scrollPosition)
|
||||
.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)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onScrollPhaseChange { _, phase in
|
||||
scrollPhase = phase
|
||||
}
|
||||
.onScrollGeometryChange(for: AgentScrollMetrics.self) { geometry in
|
||||
AgentScrollMetrics(offset: geometry.contentOffset.y,
|
||||
height: geometry.containerSize.height,
|
||||
contentHeight: geometry.contentSize.height,
|
||||
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top,
|
||||
visibleBottom: geometry.visibleRect.maxY)
|
||||
} action: { _, next in
|
||||
#if DEBUG
|
||||
AgentRenderDiagnostics.record("scrollGeometry")
|
||||
#endif
|
||||
scrollTracking.metrics = next
|
||||
if scrollTracking.atBottom != next.atBottom { scrollTracking.atBottom = next.atBottom }
|
||||
// Only user scrolling changes follow intent. Animated Latest
|
||||
// navigation and native keyboard resizing must not cancel it.
|
||||
if (isScrolling && scrollPhase != .animating) || next.contentHeight <= next.height {
|
||||
atLatest = next.atBottom
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(AgentRowFrames.self) { frames in
|
||||
#if DEBUG
|
||||
AgentRenderDiagnostics.record("rowPreferences")
|
||||
#endif
|
||||
let anchor = !atLatest && !isScrolling ? visibleReadingAnchor : nil
|
||||
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
|
||||
// Preserve ordinary reading position after new messages or
|
||||
// text reflow. Normalize the saved frames to
|
||||
// the requested offset so the next layout cannot undo it.
|
||||
let requested = scrollTracking.metrics.offset + frame.minY - anchor.offset
|
||||
let target = min(max(requested, -scrollTracking.metrics.topInset), scrollTracking.metrics.maximumOffset)
|
||||
let appliedShift = target - scrollTracking.metrics.offset
|
||||
scrollTracking.rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
|
||||
if abs(appliedShift) > 1 {
|
||||
// Geometry reports the raw offset; ScrollPosition uses
|
||||
// zero at the top inset, including the safe area.
|
||||
scrollPosition.scrollTo(y: target + scrollTracking.metrics.topInset)
|
||||
}
|
||||
} else {
|
||||
scrollTracking.rowFrames = frames
|
||||
}
|
||||
.onAppear {
|
||||
appeared = true
|
||||
if !room.isLoading { historyStartID = displayed.first?.id }
|
||||
}
|
||||
.onChange(of: room.isLoading) { _, loading in
|
||||
if !loading && historyStartID == nil { historyStartID = displayed.first?.id }
|
||||
}
|
||||
.onChange(of: allMessages.count) { _, _ in
|
||||
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
|
||||
}
|
||||
.onChange(of: latestRequest) { _, _ in
|
||||
atLatest = true
|
||||
proxy.scrollTo(bottomID, anchor: .bottom)
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if !atLatest {
|
||||
Button {
|
||||
atLatest = true
|
||||
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { scrollPosition.scrollTo(edge: .bottom) }
|
||||
} label: {
|
||||
Label(room.unreadCount > 0 ? "\(room.unreadCount) unread" : "Latest", systemImage: "arrow.down")
|
||||
.font(.sybil(.caption, weight: .semibold))
|
||||
.padding(12)
|
||||
.background(SybilTheme.surfaceStrong, in: Capsule())
|
||||
.overlay(Capsule().stroke(SybilTheme.border))
|
||||
}
|
||||
.padding(14)
|
||||
.accessibilityIdentifier("agent-jump-latest")
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
appeared = true
|
||||
if !room.isLoading { historyStartID = displayed.first?.id }
|
||||
}
|
||||
.onChange(of: room.isLoading) { _, loading in
|
||||
if !loading && historyStartID == nil { historyStartID = displayed.first?.id }
|
||||
}
|
||||
.onChange(of: allMessages.count) { _, _ in
|
||||
if atLatest { scrollPosition.scrollTo(edge: .bottom) }
|
||||
}
|
||||
.onChange(of: latestRequest) { _, _ in
|
||||
atLatest = true
|
||||
scrollPosition.scrollTo(edge: .bottom)
|
||||
}
|
||||
.modifier(AgentReadAcknowledgement(room: room, tracking: scrollTracking,
|
||||
atLatest: atLatest, isScrolling: isScrolling))
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ final class TranscriptFadeUITests: XCTestCase {
|
||||
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest)
|
||||
await fulfillment(of: [followed], timeout: 5)
|
||||
composer.tap()
|
||||
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(latest.isHittable)
|
||||
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
|
||||
capture("agent-fade-latest-with-keyboard")
|
||||
@@ -133,5 +134,27 @@ final class TranscriptFadeUITests: XCTestCase {
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
let read = try await control(["action": "state"])
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user