diff --git a/ios/Apps/Sybil/Resources/Artwork.xcassets/AppIcon.appiconset/AppIcon.png b/ios/Apps/Sybil/Resources/Artwork.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..241f8ec Binary files /dev/null and b/ios/Apps/Sybil/Resources/Artwork.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/ios/Apps/Sybil/Resources/Artwork.xcassets/AppIcon.appiconset/Contents.json b/ios/Apps/Sybil/Resources/Artwork.xcassets/AppIcon.appiconset/Contents.json index 374a543..ce8e776 100644 --- a/ios/Apps/Sybil/Resources/Artwork.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/Apps/Sybil/Resources/Artwork.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "buzzert_iOS_app_icon_prophet_Sibyl_simple_minimal_mystic_blue_b_08983f8f-941b-4ff3-abb4-9df3f3f7612d.png", + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/ios/Apps/Sybil/Resources/Artwork.xcassets/AppIcon.appiconset/buzzert_iOS_app_icon_prophet_Sibyl_simple_minimal_mystic_blue_b_08983f8f-941b-4ff3-abb4-9df3f3f7612d.png b/ios/Apps/Sybil/Resources/Artwork.xcassets/AppIcon.appiconset/buzzert_iOS_app_icon_prophet_Sibyl_simple_minimal_mystic_blue_b_08983f8f-941b-4ff3-abb4-9df3f3f7612d.png deleted file mode 100644 index cbfafa3..0000000 Binary files a/ios/Apps/Sybil/Resources/Artwork.xcassets/AppIcon.appiconset/buzzert_iOS_app_icon_prophet_Sibyl_simple_minimal_mystic_blue_b_08983f8f-941b-4ff3-abb4-9df3f3f7612d.png and /dev/null differ diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift index 2c69312..b5d047f 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift @@ -44,7 +44,7 @@ private struct AgentTranscriptView: View { let displayed = transcript.displayed ScrollViewReader { proxy in ScrollView { - LazyVStack(alignment: .leading, spacing: 15) { + LazyVStack(alignment: .leading, spacing: 0) { // The native scroll origin is the visual bottom. Keep model // and cursor order ascending; reverse only presentation. Color.clear.frame(height: 17).id(Self.bottomID) @@ -56,18 +56,22 @@ private struct AgentTranscriptView: View { } .foregroundStyle(SybilTheme.textMuted) .accessibilityIdentifier("agent-typing") + .padding(.bottom, 15) .scaleEffect(x: 1, y: -1) } ForEach(Array(displayed.enumerated().reversed()), id: \.element.id) { index, message in AgentTranscriptRow(message: message, room: room, startsDay: index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt)) .equatable() + // Include the gap in the occupied-space entrance. + .padding(.bottom, 15) .modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion)) .scaleEffect(x: 1, y: -1) .id(message.id) } if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) + .padding(.bottom, 15) .scaleEffect(x: 1, y: -1) } if allMessages.count > displayed.count { @@ -78,6 +82,7 @@ private struct AgentTranscriptView: View { } .frame(maxWidth: .infinity) .padding(.vertical, 10) + .padding(.bottom, 15) .scaleEffect(x: 1, y: -1) } } @@ -104,28 +109,12 @@ private struct AgentTranscriptView: View { AgentRenderDiagnostics.record("scrollGeometry") #endif scrollTracking.atBottom = atBottom - // Only user scrolling changes follow intent. Animated Latest - // navigation and native keyboard resizing must not cancel it. + // Only user scrolling changes follow intent. Send navigation + // and native keyboard resizing must not cancel it. if isScrolling && scrollPhase != .animating { 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 { appeared = true if !room.isLoading { @@ -306,17 +295,37 @@ private struct AgentMessageRow: View { } } -// Keep the row's measured height stable for lazy layout. Animate its appearance -// without collapsing the scroll target while the composer or keyboard resizes. +// Grow the occupied space with the bubble's entrance so neighboring rows move +// 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 { var animate: Bool var reduceMotion: Bool @State private var entered = false func body(content: Content) -> some View { let preparing = animate && !entered && !reduceMotion - content - .opacity(preparing ? 0 : 1) - .offset(y: preparing ? 16 : 0) + AgentEntranceLayout(progress: preparing ? 0 : 1) { + content + .opacity(preparing ? 0 : 1) + .offset(y: preparing ? 16 : 0) + } + .clipped() .onAppear { withAnimation(animate && !reduceMotion ? .easeOut(duration: 0.24) : nil) { entered = true } } diff --git a/ios/Tests/SybilPresentationTests/AgentPerformanceTests.swift b/ios/Tests/SybilPresentationTests/AgentPerformanceTests.swift index 16b5e86..b0263f1 100644 --- a/ios/Tests/SybilPresentationTests/AgentPerformanceTests.swift +++ b/ios/Tests/SybilPresentationTests/AgentPerformanceTests.swift @@ -7,6 +7,51 @@ import SwiftUI /// not GPU presentation, touch latency, or physical-device frame rate. @MainActor 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 { let room = SybilAgentRoom() var rows: [Message] = [] diff --git a/ios/Tests/SybilUITests/AgentUITests.swift b/ios/Tests/SybilUITests/AgentUITests.swift index 64e19bb..385c05d 100644 --- a/ios/Tests/SybilUITests/AgentUITests.swift +++ b/ios/Tests/SybilUITests/AgentUITests.swift @@ -313,7 +313,8 @@ final class AgentUITests: XCTestCase { XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable) 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 before = anchor.frame.minY 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) app.buttons["Load earlier messages"].tap() // 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) capture("agent-older-history-loaded") - app.buttons["agent-jump-latest"].tap() - XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable) + let latest = app.staticTexts["Reply while reading earlier history"] + 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") XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable) 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 { diff --git a/ios/Tests/SybilUITests/TranscriptFadeUITests.swift b/ios/Tests/SybilUITests/TranscriptFadeUITests.swift index 68ddb77..effd3de 100644 --- a/ios/Tests/SybilUITests/TranscriptFadeUITests.swift +++ b/ios/Tests/SybilUITests/TranscriptFadeUITests.swift @@ -41,6 +41,17 @@ final class TranscriptFadeUITests: XCTestCase { 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) { let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) attachment.name = name; attachment.lifetime = .keepAlways; add(attachment) @@ -146,7 +157,7 @@ final class TranscriptFadeUITests: XCTestCase { let scroll = transcript XCTAssertTrue(scroll.exists) 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") XCTAssertTrue(composer.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) 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. let visibleTop = scroll.frame.minY + 100 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 stateBefore = try await control(["action": "state"]) 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"]) - await fulfillment(of: [unread], timeout: 8) + // The removed button no longer exposes unread state. Allow the entrance + // 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) let stateAfter = try await control(["action": "state"]) XCTAssertEqual(stateAfter["readCursor"] as? Int, stateBefore["readCursor"] as? Int) @@ -191,8 +204,8 @@ final class TranscriptFadeUITests: XCTestCase { XCTAssertFalse(load.exists) capture("agent-fade-older-history-loaded") - app.buttons["agent-jump-latest"].tap() let latest = app.staticTexts["Fade regression reply while reading"] + scrollToLatest(latest) let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest) await fulfillment(of: [followed], timeout: 5) composer.tap() @@ -217,14 +230,14 @@ final class TranscriptFadeUITests: XCTestCase { 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. + // Interactive dismissal is user navigation; scrolling 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() } + scrollToLatest(keyboardReply) XCTAssertTrue(keyboardReply.isHittable) composer.tap() XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))