From 019e42e0133201bc2cd5a268107227a12136ffde Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Jul 2026 21:41:47 +0100 Subject: [PATCH] fix(ios): auto-scroll to the reply when sending with the keyboard open (#110944) --- apps/.i18n/native-source.json | 26 ++++++++-------- .../ios/UITests/OpenClawSnapshotUITests.swift | 31 +++++++++++++++++++ .../Sources/OpenClawChatUI/ChatView.swift | 21 +++++++++++-- 3 files changed, 63 insertions(+), 15 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 9d614ad1d782..b9f2dce9ea78 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -37947,7 +37947,7 @@ }, { "kind": "ui-call", - "line": 504, + "line": 513, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Retry Send", "surface": "apple", @@ -37955,7 +37955,7 @@ }, { "kind": "ui-call", - "line": 518, + "line": 527, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Delete", "surface": "apple", @@ -37963,7 +37963,7 @@ }, { "kind": "ui-call", - "line": 552, + "line": 561, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Stop Listening", "surface": "apple", @@ -37971,7 +37971,7 @@ }, { "kind": "ui-call", - "line": 555, + "line": 564, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Listen", "surface": "apple", @@ -37979,7 +37979,7 @@ }, { "kind": "ui-modifier", - "line": 650, + "line": 659, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Jump to latest reply", "surface": "apple", @@ -37987,7 +37987,7 @@ }, { "kind": "ui-named-argument", - "line": 670, + "line": 679, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Refresh", "surface": "apple", @@ -37995,7 +37995,7 @@ }, { "kind": "ui-call", - "line": 1057, + "line": 1074, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Copy Message", "surface": "apple", @@ -38003,7 +38003,7 @@ }, { "kind": "ui-call", - "line": 1080, + "line": 1097, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Open Full Message", "surface": "apple", @@ -38011,7 +38011,7 @@ }, { "kind": "ui-localized-call", - "line": 1101, + "line": 1118, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Reply", "surface": "apple", @@ -38019,7 +38019,7 @@ }, { "kind": "ui-localized-call", - "line": 1111, + "line": 1128, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "You", "surface": "apple", @@ -38027,7 +38027,7 @@ }, { "kind": "ui-localized-call", - "line": 1113, + "line": 1130, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Assistant", "surface": "apple", @@ -38035,7 +38035,7 @@ }, { "kind": "ui-call", - "line": 1179, + "line": 1196, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Loading chat", "surface": "apple", @@ -38043,7 +38043,7 @@ }, { "kind": "ui-modifier", - "line": 1259, + "line": 1276, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Dismiss", "surface": "apple", diff --git a/apps/ios/UITests/OpenClawSnapshotUITests.swift b/apps/ios/UITests/OpenClawSnapshotUITests.swift index 5a60edc8880e..6a121c560277 100644 --- a/apps/ios/UITests/OpenClawSnapshotUITests.swift +++ b/apps/ios/UITests/OpenClawSnapshotUITests.swift @@ -353,6 +353,37 @@ final class OpenClawSnapshotUITests: XCTestCase { XCTAssertTrue(self.app?.keyboards.firstMatch.waitForNonExistence(timeout: 3) == true) } + func testKeyboardOpenSendFollowsLiveEdge() throws { + try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone keyboard proof only") + self.launchApp(for: ScreenshotTarget( + initialTab: "chat", + initialDestination: "chat", + name: "keyboard-follow")) + let app = try XCTUnwrap(self.app) + + let input = app.textFields["chat-message-input"] + XCTAssertTrue(input.waitForExistence(timeout: 8)) + input.tap() + input.typeText( + "Give me a long, detailed status update covering the release plan, review feedback, " + + "open follow-ups, and the next steps for the team.") + let send = app.buttons["chat-send-message"] + XCTAssertTrue(send.waitForExistence(timeout: 5)) + send.tap() + + // Regression proof for #108692: with the keyboard still up, the reply must scroll into + // view above the keyboard on its own — no jump affordance, no manual scrolling. + let keyboard = app.keyboards.firstMatch + XCTAssertTrue(keyboard.exists) + let reply = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS %@", "keep the mobile workflow connected to the gateway")) + .firstMatch + XCTAssertTrue(reply.waitForExistence(timeout: 8)) + Thread.sleep(forTimeInterval: 1.0) + XCTAssertLessThanOrEqual(reply.frame.maxY, keyboard.frame.minY + 1) + XCTAssertFalse(app.buttons["Jump to latest reply"].exists) + } + func testChatPresentationInLightAppearance() throws { try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone chat proof only") self.launchApp( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift index 5fdc499edab9..e8a981185429 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift @@ -117,6 +117,7 @@ public struct OpenClawChatView: View { @State private var followTarget: ScrollFollowTarget? = .latest @State private var isAtLiveEdge = true @State private var isUserScrolling = false + @State private var isKeyboardVisible = false @State private var fullMessageRequest: ChatFullMessageReaderRequest? private let showsSessionSwitcher: Bool private let drawsBackground: Bool @@ -400,6 +401,14 @@ public struct OpenClawChatView: View { .onChange(of: self.viewModel.timelineRevision) { _, _ in self.handleTimelineChange() } + #if canImport(UIKit) && !os(macOS) + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in + self.isKeyboardVisible = true + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in + self.isKeyboardVisible = false + } + #endif } @ViewBuilder @@ -848,9 +857,17 @@ public struct OpenClawChatView: View { return case let .added(latestUserMessageID): self.lastUserMessageID = latestUserMessageID - self.followTarget = .user(latestUserMessageID) self.hasNewerContentBelow = false - self.moveScrollPosition(to: latestUserMessageID, anchor: Layout.newTurnAnchor) + // The anchored-question layout assumes a viewport tall enough to read the turn + // below the anchor. With the keyboard up that space is gone and the reply streams + // straight past the fold (#108692), so follow the live edge instead. + if self.isKeyboardVisible { + self.followTarget = .latest + self.moveScrollPosition(to: self.scrollerBottomID) + } else { + self.followTarget = .user(latestUserMessageID) + self.moveScrollPosition(to: latestUserMessageID, anchor: Layout.newTurnAnchor) + } return case .unchanged: break