diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift index 7561ecf..8a10090 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift @@ -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)) } diff --git a/ios/Tests/SybilUITests/TranscriptFadeUITests.swift b/ios/Tests/SybilUITests/TranscriptFadeUITests.swift index ee8f5fa..c603ff0 100644 --- a/ios/Tests/SybilUITests/TranscriptFadeUITests.swift +++ b/ios/Tests/SybilUITests/TranscriptFadeUITests.swift @@ -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) } }