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
@@ -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 }
}