ios: smooth Agent entrances and refresh app icon
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:
2026-10-16 16:54:46 -07:00
parent f59c7db7bb
commit 671ee98ab1
7 changed files with 118 additions and 37 deletions
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"
@@ -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
content AgentEntranceLayout(progress: preparing ? 0 : 1) {
.opacity(preparing ? 0 : 1) content
.offset(y: preparing ? 16 : 0) .opacity(preparing ? 0 : 1)
.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] = []
+18 -4
View File
@@ -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))