ios: smooth Agent entrances and refresh app icon
TestFlight / Build and upload (push) Successful in 1m49s
TestFlight / Build and upload (push) Successful in 1m49s
Grow row height and spacing with the existing entrance animation, remove the Latest overlay while retaining scroll/read/send behavior, and reuse the current PWA icon artwork. Cover occupied-height interpolation and scroll-to-latest navigation in focused checks.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 791 KiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "buzzert_iOS_app_icon_prophet_Sibyl_simple_minimal_mystic_blue_b_08983f8f-941b-4ff3-abb4-9df3f3f7612d.png",
|
"filename" : "AppIcon.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|||||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 877 KiB |
@@ -44,7 +44,7 @@ private struct AgentTranscriptView: View {
|
|||||||
let displayed = transcript.displayed
|
let displayed = transcript.displayed
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 15) {
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
// The native scroll origin is the visual bottom. Keep model
|
// The native scroll origin is the visual bottom. Keep model
|
||||||
// and cursor order ascending; reverse only presentation.
|
// and cursor order ascending; reverse only presentation.
|
||||||
Color.clear.frame(height: 17).id(Self.bottomID)
|
Color.clear.frame(height: 17).id(Self.bottomID)
|
||||||
@@ -56,18 +56,22 @@ private struct AgentTranscriptView: View {
|
|||||||
}
|
}
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.accessibilityIdentifier("agent-typing")
|
.accessibilityIdentifier("agent-typing")
|
||||||
|
.padding(.bottom, 15)
|
||||||
.scaleEffect(x: 1, y: -1)
|
.scaleEffect(x: 1, y: -1)
|
||||||
}
|
}
|
||||||
ForEach(Array(displayed.enumerated().reversed()), id: \.element.id) { index, message in
|
ForEach(Array(displayed.enumerated().reversed()), id: \.element.id) { index, message in
|
||||||
AgentTranscriptRow(message: message, room: room,
|
AgentTranscriptRow(message: message, room: room,
|
||||||
startsDay: index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt))
|
startsDay: index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt))
|
||||||
.equatable()
|
.equatable()
|
||||||
|
// Include the gap in the occupied-space entrance.
|
||||||
|
.padding(.bottom, 15)
|
||||||
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
|
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
|
||||||
.scaleEffect(x: 1, y: -1)
|
.scaleEffect(x: 1, y: -1)
|
||||||
.id(message.id)
|
.id(message.id)
|
||||||
}
|
}
|
||||||
if room.isLoading {
|
if room.isLoading {
|
||||||
ProgressView("Loading messages…").frame(maxWidth: .infinity)
|
ProgressView("Loading messages…").frame(maxWidth: .infinity)
|
||||||
|
.padding(.bottom, 15)
|
||||||
.scaleEffect(x: 1, y: -1)
|
.scaleEffect(x: 1, y: -1)
|
||||||
}
|
}
|
||||||
if allMessages.count > displayed.count {
|
if allMessages.count > displayed.count {
|
||||||
@@ -78,6 +82,7 @@ private struct AgentTranscriptView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
|
.padding(.bottom, 15)
|
||||||
.scaleEffect(x: 1, y: -1)
|
.scaleEffect(x: 1, y: -1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,28 +109,12 @@ private struct AgentTranscriptView: View {
|
|||||||
AgentRenderDiagnostics.record("scrollGeometry")
|
AgentRenderDiagnostics.record("scrollGeometry")
|
||||||
#endif
|
#endif
|
||||||
scrollTracking.atBottom = atBottom
|
scrollTracking.atBottom = atBottom
|
||||||
// Only user scrolling changes follow intent. Animated Latest
|
// Only user scrolling changes follow intent. Send navigation
|
||||||
// navigation and native keyboard resizing must not cancel it.
|
// and native keyboard resizing must not cancel it.
|
||||||
if isScrolling && scrollPhase != .animating {
|
if isScrolling && scrollPhase != .animating {
|
||||||
atLatest = atBottom
|
atLatest = atBottom
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overlay(alignment: .bottomTrailing) {
|
|
||||||
if !atLatest {
|
|
||||||
Button {
|
|
||||||
atLatest = true
|
|
||||||
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(Self.bottomID, anchor: .top) }
|
|
||||||
} 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 {
|
.onAppear {
|
||||||
appeared = true
|
appeared = true
|
||||||
if !room.isLoading {
|
if !room.isLoading {
|
||||||
@@ -306,17 +295,37 @@ private struct AgentMessageRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the row's measured height stable for lazy layout. Animate its appearance
|
// Grow the occupied space with the bubble's entrance so neighboring rows move
|
||||||
// without collapsing the scroll target while the composer or keyboard resizes.
|
// smoothly. Measure at natural height; no geometry state or scroll correction.
|
||||||
|
private struct AgentEntranceLayout: Layout {
|
||||||
|
var progress: CGFloat
|
||||||
|
var animatableData: CGFloat {
|
||||||
|
get { progress }
|
||||||
|
set { progress = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||||
|
let size = subviews.first?.sizeThatFits(.init(width: proposal.width, height: nil)) ?? .zero
|
||||||
|
return CGSize(width: size.width, height: size.height * progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||||
|
subviews.first?.place(at: bounds.origin, proposal: .init(width: bounds.width, height: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct AgentEntrance: ViewModifier {
|
private struct AgentEntrance: ViewModifier {
|
||||||
var animate: Bool
|
var animate: Bool
|
||||||
var reduceMotion: Bool
|
var reduceMotion: Bool
|
||||||
@State private var entered = false
|
@State private var entered = false
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
let preparing = animate && !entered && !reduceMotion
|
let preparing = animate && !entered && !reduceMotion
|
||||||
|
AgentEntranceLayout(progress: preparing ? 0 : 1) {
|
||||||
content
|
content
|
||||||
.opacity(preparing ? 0 : 1)
|
.opacity(preparing ? 0 : 1)
|
||||||
.offset(y: preparing ? 16 : 0)
|
.offset(y: preparing ? 16 : 0)
|
||||||
|
}
|
||||||
|
.clipped()
|
||||||
.onAppear {
|
.onAppear {
|
||||||
withAnimation(animate && !reduceMotion ? .easeOut(duration: 0.24) : nil) { entered = true }
|
withAnimation(animate && !reduceMotion ? .easeOut(duration: 0.24) : nil) { entered = true }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,51 @@ import SwiftUI
|
|||||||
/// not GPU presentation, touch latency, or physical-device frame rate.
|
/// not GPU presentation, touch latency, or physical-device frame rate.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class AgentPerformanceTests: XCTestCase {
|
final class AgentPerformanceTests: XCTestCase {
|
||||||
|
func testEntranceGrowsOccupiedHeight() async throws {
|
||||||
|
XCTAssertFalse(UIAccessibility.isReduceMotionEnabled, "This check must exercise the entrance animation")
|
||||||
|
let room = SybilAgentRoom()
|
||||||
|
let rows = (1...3).map { index in
|
||||||
|
Message(id: "entrance-\(index)", createdAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||||
|
role: .assistant, content: "Existing neighbor \(index)")
|
||||||
|
}
|
||||||
|
try room.receive(.messages(AgentPage(conversationId: "entrance", messages: rows,
|
||||||
|
cursor: "3", beforeCursor: nil, hasMore: false)), replay: true)
|
||||||
|
room.flush()
|
||||||
|
let host = UIHostingController(rootView: SybilAgentView(room: room))
|
||||||
|
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
||||||
|
let window = UIWindow(windowScene: scene)
|
||||||
|
window.rootViewController = host
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
defer { window.isHidden = true; window.rootViewController = nil; room.stop() }
|
||||||
|
host.view.frame = window.bounds
|
||||||
|
host.view.layoutIfNeeded()
|
||||||
|
try await Task.sleep(for: .milliseconds(500))
|
||||||
|
func transcript(in view: UIView) -> UIScrollView? {
|
||||||
|
if let scroll = view as? UIScrollView, scroll.contentSize.height > 100 { return scroll }
|
||||||
|
return view.subviews.lazy.compactMap { transcript(in: $0) }.first
|
||||||
|
}
|
||||||
|
let scroll = try XCTUnwrap(transcript(in: host.view))
|
||||||
|
let before = scroll.contentSize.height
|
||||||
|
let reply = Message(id: "entrance-4", createdAt: rows[0].createdAt, role: .assistant,
|
||||||
|
content: "Incoming reply\n\nIts occupied space should grow with the bubble.")
|
||||||
|
try room.receive(.messages(AgentPage(conversationId: "entrance", messages: [reply],
|
||||||
|
cursor: "4", beforeCursor: nil, hasMore: false)))
|
||||||
|
room.flush()
|
||||||
|
var heights: [CGFloat] = []
|
||||||
|
for _ in 0..<32 {
|
||||||
|
try await Task.sleep(for: .milliseconds(16))
|
||||||
|
heights.append(scroll.contentSize.height)
|
||||||
|
}
|
||||||
|
let after = try XCTUnwrap(heights.last)
|
||||||
|
// All four short rows fit onscreen, so these are measured heights, not
|
||||||
|
// estimates for unmaterialized lazy history. No frame-rate claim.
|
||||||
|
XCTAssertLessThan(after, scroll.bounds.height)
|
||||||
|
XCTAssertGreaterThan(after - before, 50)
|
||||||
|
let intermediate = Set(heights.filter { $0 > before + 2 && $0 < after - 2 }.map { Int($0.rounded()) })
|
||||||
|
XCTAssertGreaterThanOrEqual(intermediate.count, 3, "Neighbors must get intermediate layout heights, not a full-height insertion")
|
||||||
|
print("AGENT_ENTRANCE_HEIGHT before=\(before) samples=\(heights)")
|
||||||
|
}
|
||||||
|
|
||||||
func testThinkingScrollResponsiveness() async throws {
|
func testThinkingScrollResponsiveness() async throws {
|
||||||
let room = SybilAgentRoom()
|
let room = SybilAgentRoom()
|
||||||
var rows: [Message] = []
|
var rows: [Message] = []
|
||||||
|
|||||||
@@ -313,7 +313,8 @@ final class AgentUITests: XCTestCase {
|
|||||||
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||||
scroll.swipeDown()
|
scroll.swipeDown()
|
||||||
scroll.swipeDown()
|
scroll.swipeDown()
|
||||||
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 3))
|
XCTAssertFalse(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||||
|
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
|
||||||
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first { $0.isHittable })
|
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first { $0.isHittable })
|
||||||
let before = anchor.frame.minY
|
let before = anchor.frame.minY
|
||||||
try await control(["action": "reply", "content": "Reply while reading earlier history"])
|
try await control(["action": "reply", "content": "Reply while reading earlier history"])
|
||||||
@@ -327,14 +328,27 @@ final class AgentUITests: XCTestCase {
|
|||||||
XCTAssertTrue(app.buttons["Load earlier messages"].isHittable)
|
XCTAssertTrue(app.buttons["Load earlier messages"].isHittable)
|
||||||
app.buttons["Load earlier messages"].tap()
|
app.buttons["Load earlier messages"].tap()
|
||||||
// Loading older lazy history may move the viewport. It must still load
|
// Loading older lazy history may move the viewport. It must still load
|
||||||
// the remaining history and preserve Latest/keyboard behavior below.
|
// the remaining history and preserve scroll-to-latest/keyboard behavior below.
|
||||||
XCTAssertFalse(app.buttons["Load earlier messages"].exists)
|
XCTAssertFalse(app.buttons["Load earlier messages"].exists)
|
||||||
capture("agent-older-history-loaded")
|
capture("agent-older-history-loaded")
|
||||||
app.buttons["agent-jump-latest"].tap()
|
let latest = app.staticTexts["Reply while reading earlier history"]
|
||||||
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
|
for _ in 0..<30 {
|
||||||
|
if latest.isHittable { break }
|
||||||
|
scroll.swipeUp(velocity: .fast)
|
||||||
|
}
|
||||||
|
scroll.swipeUp(velocity: .slow)
|
||||||
|
XCTAssertTrue(latest.isHittable)
|
||||||
composer.tap(); composer.typeText("A multiline draft\nwith a second line\nand a third line")
|
composer.tap(); composer.typeText("A multiline draft\nwith a second line\nand a third line")
|
||||||
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
|
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
|
||||||
capture("agent-multiline-keyboard")
|
capture("agent-multiline-keyboard")
|
||||||
|
// Sending remains the explicit navigation path while reading history.
|
||||||
|
scroll.swipeDown(velocity: .slow)
|
||||||
|
scroll.swipeDown(velocity: .slow)
|
||||||
|
XCTAssertFalse(latest.isHittable)
|
||||||
|
app.buttons["agent-send"].tap()
|
||||||
|
let sent = app.staticTexts["A multiline draft\nwith a second line\nand a third line"]
|
||||||
|
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: sent)
|
||||||
|
await fulfillment(of: [followed], timeout: 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBackgroundUnreadReadReconciliationAndReplay() async throws {
|
func testBackgroundUnreadReadReconciliationAndReplay() async throws {
|
||||||
|
|||||||
@@ -41,6 +41,17 @@ final class TranscriptFadeUITests: XCTestCase {
|
|||||||
app.scrollViews["agent-transcript"]
|
app.scrollViews["agent-transcript"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func scrollToLatest(_ latest: XCUIElement) {
|
||||||
|
for _ in 0..<30 {
|
||||||
|
if latest.isHittable { break }
|
||||||
|
transcript.swipeUp(velocity: .fast)
|
||||||
|
}
|
||||||
|
// Finish at the physical end, including the reply metadata and spacing.
|
||||||
|
transcript.swipeUp(velocity: .slow)
|
||||||
|
XCTAssertTrue(latest.isHittable)
|
||||||
|
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
|
||||||
|
}
|
||||||
|
|
||||||
private func capture(_ name: String) {
|
private func capture(_ name: String) {
|
||||||
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
|
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
|
||||||
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
|
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
|
||||||
@@ -146,7 +157,7 @@ final class TranscriptFadeUITests: XCTestCase {
|
|||||||
let scroll = transcript
|
let scroll = transcript
|
||||||
XCTAssertTrue(scroll.exists)
|
XCTAssertTrue(scroll.exists)
|
||||||
scroll.swipeDown(velocity: .slow)
|
scroll.swipeDown(velocity: .slow)
|
||||||
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5))
|
XCTAssertFalse(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||||
capture("agent-content-crossing-shared-fade")
|
capture("agent-content-crossing-shared-fade")
|
||||||
XCTAssertTrue(composer.isHittable)
|
XCTAssertTrue(composer.isHittable)
|
||||||
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
|
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
|
||||||
@@ -162,7 +173,8 @@ final class TranscriptFadeUITests: XCTestCase {
|
|||||||
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||||
scroll.swipeDown(velocity: .slow)
|
scroll.swipeDown(velocity: .slow)
|
||||||
scroll.swipeDown(velocity: .slow)
|
scroll.swipeDown(velocity: .slow)
|
||||||
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5))
|
XCTAssertFalse(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||||
|
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
|
||||||
// Stay below the header's content clearance, where text is readable.
|
// Stay below the header's content clearance, where text is readable.
|
||||||
let visibleTop = scroll.frame.minY + 100
|
let visibleTop = scroll.frame.minY + 100
|
||||||
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first {
|
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first {
|
||||||
@@ -171,8 +183,9 @@ final class TranscriptFadeUITests: XCTestCase {
|
|||||||
let before = anchor.frame.minY
|
let before = anchor.frame.minY
|
||||||
let stateBefore = try await control(["action": "state"])
|
let stateBefore = try await control(["action": "state"])
|
||||||
try await control(["action": "reply", "content": "Fade regression reply while reading"])
|
try await control(["action": "reply", "content": "Fade regression reply while reading"])
|
||||||
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "unread"), object: app.buttons["agent-jump-latest"])
|
// The removed button no longer exposes unread state. Allow the entrance
|
||||||
await fulfillment(of: [unread], timeout: 8)
|
// and read debounce to settle, then inspect the fixture's actual cursor.
|
||||||
|
try await Task.sleep(for: .milliseconds(500))
|
||||||
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
|
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
|
||||||
let stateAfter = try await control(["action": "state"])
|
let stateAfter = try await control(["action": "state"])
|
||||||
XCTAssertEqual(stateAfter["readCursor"] as? Int, stateBefore["readCursor"] as? Int)
|
XCTAssertEqual(stateAfter["readCursor"] as? Int, stateBefore["readCursor"] as? Int)
|
||||||
@@ -191,8 +204,8 @@ final class TranscriptFadeUITests: XCTestCase {
|
|||||||
XCTAssertFalse(load.exists)
|
XCTAssertFalse(load.exists)
|
||||||
capture("agent-fade-older-history-loaded")
|
capture("agent-fade-older-history-loaded")
|
||||||
|
|
||||||
app.buttons["agent-jump-latest"].tap()
|
|
||||||
let latest = app.staticTexts["Fade regression reply while reading"]
|
let latest = app.staticTexts["Fade regression reply while reading"]
|
||||||
|
scrollToLatest(latest)
|
||||||
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()
|
||||||
@@ -217,14 +230,14 @@ final class TranscriptFadeUITests: XCTestCase {
|
|||||||
let keyboardRead = try await control(["action": "state"])
|
let keyboardRead = try await control(["action": "state"])
|
||||||
XCTAssertEqual(keyboardRead["readCursor"] as? Int, keyboardRead["cursor"] as? Int)
|
XCTAssertEqual(keyboardRead["readCursor"] as? Int, keyboardRead["cursor"] as? Int)
|
||||||
|
|
||||||
// Interactive dismissal is user navigation; Latest must still return to
|
// Interactive dismissal is user navigation; scrolling must still return
|
||||||
// the bottom, and presenting the keyboard again must keep it visible.
|
// 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 dismissStart = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9))
|
||||||
let dismissEnd = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98))
|
let dismissEnd = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98))
|
||||||
dismissStart.press(forDuration: 0.1, thenDragTo: dismissEnd)
|
dismissStart.press(forDuration: 0.1, thenDragTo: dismissEnd)
|
||||||
let dismissed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: app.keyboards.firstMatch)
|
let dismissed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: app.keyboards.firstMatch)
|
||||||
await fulfillment(of: [dismissed], timeout: 5)
|
await fulfillment(of: [dismissed], timeout: 5)
|
||||||
if app.buttons["agent-jump-latest"].exists { app.buttons["agent-jump-latest"].tap() }
|
scrollToLatest(keyboardReply)
|
||||||
XCTAssertTrue(keyboardReply.isHittable)
|
XCTAssertTrue(keyboardReply.isHittable)
|
||||||
composer.tap()
|
composer.tap()
|
||||||
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
|
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
|
||||||
|
|||||||
Reference in New Issue
Block a user