Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15efbcc205 | ||
|
|
ff48a25186 | ||
|
|
e681e9e05c | ||
|
|
d5427926d5 | ||
|
|
bbaa5e8859 |
@@ -1,30 +1,37 @@
|
||||
import SwiftUI
|
||||
import Observation
|
||||
|
||||
struct SybilAgentView: View {
|
||||
@Bindable var room: SybilAgentRoom
|
||||
var room: SybilAgentRoom
|
||||
var topInset: CGFloat = 0
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var latestRequest = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
AgentTranscriptView(room: room, topInset: topInset, latestRequest: latestRequest)
|
||||
AgentStatusBar(room: room)
|
||||
AgentComposer(room: room) { latestRequest += 1 }
|
||||
}
|
||||
.onAppear { room.isSelected = true }
|
||||
.onDisappear { room.isSelected = false }
|
||||
}
|
||||
}
|
||||
|
||||
// Draft edits and keyboard focus belong to the composer. The transcript observes
|
||||
// only room/render/scroll state; SwiftUI handles keyboard avoidance.
|
||||
private struct AgentTranscriptView: View {
|
||||
var room: SybilAgentRoom
|
||||
var topInset: CGFloat
|
||||
var latestRequest: Int
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@FocusState private var composerFocused: Bool
|
||||
@State private var atLatest = true
|
||||
@State private var isScrolling = false
|
||||
@State private var scrollPhase: ScrollPhase = .idle
|
||||
@State private var historyLimit = 100
|
||||
@State private var historyStartID: String?
|
||||
@State private var scrollPosition = ScrollPosition()
|
||||
private static let bottomID = "agent-bottom"
|
||||
@State private var scrollTracking = AgentScrollTracking()
|
||||
@State private var atBottom = false
|
||||
@State private var restoreAnchor: AgentReadingAnchor?
|
||||
@State private var appeared = false
|
||||
private let bottomID = "agent-bottom"
|
||||
|
||||
private var readKey: String {
|
||||
"\(room.snapshot.room.cursor):\(atLatest):\(atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
|
||||
}
|
||||
private var visibleReadingAnchor: AgentReadingAnchor? {
|
||||
scrollTracking.rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollTracking.metrics.height }
|
||||
.min { $0.value.minY < $1.value.minY }
|
||||
.map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) }
|
||||
}
|
||||
private var isScrolling: Bool { scrollPhase.isScrolling }
|
||||
|
||||
var body: some View {
|
||||
#if DEBUG
|
||||
@@ -35,149 +42,146 @@ struct SybilAgentView: View {
|
||||
let allMessages = transcript.messages
|
||||
let startIndex = transcript.startIndex
|
||||
let displayed = transcript.displayed
|
||||
VStack(spacing: 0) {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 15) {
|
||||
if allMessages.count > displayed.count {
|
||||
Button("Load earlier messages") {
|
||||
if var anchor = visibleReadingAnchor {
|
||||
anchor.expectedCount = min(allMessages.count, displayed.count + 100)
|
||||
restoreAnchor = anchor
|
||||
}
|
||||
atLatest = false
|
||||
historyLimit += 100
|
||||
historyStartID = allMessages[max(0, startIndex - 100)].id
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 15) {
|
||||
// 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)
|
||||
if rendered.isTyping {
|
||||
HStack(spacing: 9) {
|
||||
AgentAvatar()
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Sybil is thinking…").font(.sybil(.caption))
|
||||
}
|
||||
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
|
||||
ForEach(Array(displayed.enumerated()), 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()
|
||||
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
|
||||
.id(message.id)
|
||||
}
|
||||
if rendered.isTyping {
|
||||
HStack(spacing: 9) {
|
||||
AgentAvatar()
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Sybil is thinking…").font(.sybil(.caption))
|
||||
}
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.accessibilityIdentifier("agent-typing")
|
||||
}
|
||||
Color.clear.frame(height: 1).id(bottomID)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.accessibilityIdentifier("agent-typing")
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 16)
|
||||
// Match normal chat: content clears the header, while the
|
||||
// viewport extends beneath the shared navigation fade.
|
||||
.padding(.top, 16 + topInset)
|
||||
.frame(maxWidth: SybilLayout.webContentMaxWidth)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.coordinateSpace(name: "agent-scroll")
|
||||
.scrollPosition($scrollPosition)
|
||||
.defaultScrollAnchor(.bottom, for: .initialOffset)
|
||||
.defaultScrollAnchor(.bottom, for: .alignment)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onScrollPhaseChange { _, phase in
|
||||
isScrolling = phase.isScrolling
|
||||
if phase == .interacting || phase == .tracking { restoreAnchor = nil }
|
||||
}
|
||||
.onScrollGeometryChange(for: AgentScrollMetrics.self) { geometry in
|
||||
AgentScrollMetrics(offset: geometry.contentOffset.y,
|
||||
height: geometry.containerSize.height,
|
||||
contentHeight: geometry.contentSize.height,
|
||||
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top,
|
||||
visibleBottom: geometry.visibleRect.maxY)
|
||||
} action: { previous, next in
|
||||
#if DEBUG
|
||||
AgentRenderDiagnostics.record("scrollGeometry")
|
||||
#endif
|
||||
scrollTracking.metrics = next
|
||||
if atBottom != next.atBottom { atBottom = next.atBottom }
|
||||
if isScrolling || next.contentHeight <= next.height { atLatest = next.atBottom }
|
||||
if atLatest, !isScrolling, restoreAnchor == nil,
|
||||
previous.height != next.height || previous.contentHeight != next.contentHeight || previous.bottomInset != next.bottomInset {
|
||||
proxy.scrollTo(bottomID, anchor: .bottom)
|
||||
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()
|
||||
.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)
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
if allMessages.count > displayed.count {
|
||||
Button("Load earlier messages") {
|
||||
atLatest = false
|
||||
historyLimit += 100
|
||||
historyStartID = allMessages[max(0, startIndex - 100)].id
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(AgentRowFrames.self) { frames in
|
||||
#if DEBUG
|
||||
AgentRenderDiagnostics.record("rowPreferences")
|
||||
#endif
|
||||
let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? visibleReadingAnchor : nil)
|
||||
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
|
||||
// Restore the exact pixel offset after insertion or text
|
||||
// reflow. Normalize the saved frames to
|
||||
// the requested offset so the next layout cannot undo it.
|
||||
let requested = scrollTracking.metrics.offset + frame.minY - anchor.offset
|
||||
let target = min(max(requested, -scrollTracking.metrics.topInset), scrollTracking.metrics.maximumOffset)
|
||||
let appliedShift = target - scrollTracking.metrics.offset
|
||||
scrollTracking.rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
|
||||
if abs(appliedShift) > 1 {
|
||||
// Geometry reports the raw offset; ScrollPosition uses
|
||||
// zero at the top inset, including the safe area.
|
||||
scrollPosition.scrollTo(y: target + scrollTracking.metrics.topInset)
|
||||
}
|
||||
restoreAnchor = nil
|
||||
} else {
|
||||
scrollTracking.rowFrames = frames
|
||||
if let expected = restoreAnchor?.expectedCount, frames.count >= expected { restoreAnchor = nil }
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if !atLatest {
|
||||
Button {
|
||||
atLatest = true
|
||||
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(bottomID, anchor: .bottom) }
|
||||
} 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 { historyStartID = displayed.first?.id }
|
||||
}
|
||||
.onChange(of: room.isLoading) { _, loading in
|
||||
if !loading && historyStartID == nil { historyStartID = displayed.first?.id }
|
||||
}
|
||||
.onChange(of: allMessages.count) { _, _ in
|
||||
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
|
||||
}
|
||||
.onChange(of: composerFocused) { _, _ in
|
||||
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
|
||||
// Keep native lazy-row targets without binding scroll position.
|
||||
.scrollTargetLayout()
|
||||
.padding(.horizontal, 16)
|
||||
// Match normal chat: content clears the header, while the
|
||||
// viewport extends beneath the shared navigation fade.
|
||||
.padding(.bottom, 16 + topInset)
|
||||
.frame(maxWidth: SybilLayout.webContentMaxWidth)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
.accessibilityIdentifier("agent-transcript")
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onScrollPhaseChange { _, phase in
|
||||
scrollPhase = phase
|
||||
}
|
||||
.onScrollGeometryChange(for: Bool.self) { geometry in
|
||||
geometry.containerSize.height > 0 &&
|
||||
geometry.contentOffset.y + geometry.contentInsets.top <= 24
|
||||
} action: { _, atBottom in
|
||||
#if DEBUG
|
||||
AgentRenderDiagnostics.record("scrollGeometry")
|
||||
#endif
|
||||
scrollTracking.atBottom = atBottom
|
||||
// Only user scrolling changes follow intent. Animated Latest
|
||||
// navigation and native keyboard resizing must not cancel it.
|
||||
if isScrolling && scrollPhase != .animating {
|
||||
atLatest = atBottom
|
||||
}
|
||||
}
|
||||
statusBar
|
||||
composer
|
||||
.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 {
|
||||
historyStartID = displayed.first?.id
|
||||
}
|
||||
}
|
||||
.onChange(of: room.isLoading) { _, loading in
|
||||
if !loading && historyStartID == nil {
|
||||
historyStartID = displayed.first?.id
|
||||
}
|
||||
}
|
||||
.onChange(of: latestRequest) { _, _ in
|
||||
if !atLatest {
|
||||
atLatest = true
|
||||
proxy.scrollTo(Self.bottomID, anchor: .top)
|
||||
}
|
||||
}
|
||||
.modifier(AgentReadAcknowledgement(room: room, tracking: scrollTracking,
|
||||
atLatest: atLatest, isScrolling: isScrolling))
|
||||
}
|
||||
.onAppear { room.isSelected = true }
|
||||
.onDisappear { room.isSelected = false }
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom proximity can change twice per entrance/keyboard frame while the
|
||||
// scroll view follows the growing content. Only the read-settling task needs
|
||||
// those transitions; they must not project and rebuild the transcript hierarchy.
|
||||
private struct AgentReadAcknowledgement: ViewModifier {
|
||||
var room: SybilAgentRoom
|
||||
var tracking: AgentScrollTracking
|
||||
var atLatest: Bool
|
||||
var isScrolling: Bool
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
private var readKey: String {
|
||||
"\(room.snapshot.room.cursor):\(atLatest):\(tracking.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let cursor = room.snapshot.room.cursor
|
||||
content
|
||||
.task(id: readKey) {
|
||||
// This cursor belongs to the rendered snapshot. New network input cancels
|
||||
// the task, and cannot be acknowledged while still entering the layout.
|
||||
let cursor = rendered.room.cursor
|
||||
guard atLatest, scrollTracking.metrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
|
||||
// Capture the rendered cursor before scheduling. New network input
|
||||
// cancels this task and must settle in layout before acknowledgement.
|
||||
guard atLatest, tracking.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
|
||||
try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360))
|
||||
guard !Task.isCancelled, atLatest, scrollTracking.metrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
|
||||
guard !Task.isCancelled, atLatest, tracking.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
|
||||
room.markRead(renderedCursor: cursor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var statusBar: some View {
|
||||
private struct AgentStatusBar: View {
|
||||
var room: SybilAgentRoom
|
||||
|
||||
@ViewBuilder var body: some View {
|
||||
let activity = room.snapshot.room.activity
|
||||
if let error = room.error ?? activity.error {
|
||||
HStack(alignment: .top) {
|
||||
@@ -199,12 +203,18 @@ struct SybilAgentView: View {
|
||||
.padding(.horizontal, 16).padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentComposer: View {
|
||||
@Bindable var room: SybilAgentRoom
|
||||
var onSubmit: () -> Void
|
||||
@FocusState private var composerFocused: Bool
|
||||
|
||||
private var canSendComposer: Bool {
|
||||
room.attachmentDraft.canSend
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
var body: some View {
|
||||
AgentAttachmentComposer(room: room) {
|
||||
SybilPromptField(
|
||||
text: $room.composer,
|
||||
@@ -223,7 +233,7 @@ struct SybilAgentView: View {
|
||||
|
||||
private func submitComposer() {
|
||||
guard canSendComposer, room.sendComposer() else { return }
|
||||
atLatest = true
|
||||
onSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,9 +246,8 @@ struct AgentAvatar: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Activity/read publications still update the surrounding view. Completed rows
|
||||
// depend only on their message values and date heading, so progress changes
|
||||
// must not rebuild the eager history (whose exact heights preserve anchors).
|
||||
// Activity/read publications still update the transcript. Completed rows depend
|
||||
// only on message values and heading; scrolling needs no per-row geometry.
|
||||
private struct AgentTranscriptRow: View, Equatable {
|
||||
var message: Message
|
||||
var room: SybilAgentRoom
|
||||
@@ -261,9 +270,6 @@ private struct AgentTranscriptRow: View, Equatable {
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
AgentMessageRow(message: message, room: room)
|
||||
.background(GeometryReader { geometry in
|
||||
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -300,63 +306,24 @@ private struct AgentMessageRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Animate both occupied height and content, so incoming rows push older history up.
|
||||
private struct AgentGrowingLayout: 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(ProposedViewSize(width: proposal.width, height: nil)) ?? .zero
|
||||
return CGSize(width: proposal.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: ProposedViewSize(width: bounds.width, height: nil))
|
||||
}
|
||||
}
|
||||
// Keep the row's measured height stable for lazy layout. Animate its appearance
|
||||
// without collapsing the scroll target while the composer or keyboard resizes.
|
||||
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
|
||||
AgentGrowingLayout(progress: preparing ? 0 : 1) {
|
||||
content.opacity(preparing ? 0 : 1).offset(y: preparing ? 16 : 0)
|
||||
}
|
||||
.clipped()
|
||||
content
|
||||
.opacity(preparing ? 0 : 1)
|
||||
.offset(y: preparing ? 16 : 0)
|
||||
.onAppear {
|
||||
withAnimation(animate && !reduceMotion ? .easeOut(duration: 0.24) : nil) { entered = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pixel geometry is imperative anchor bookkeeping, not rendered content. Keeping
|
||||
// it outside observation avoids rebuilding the entire transcript on every scroll
|
||||
// frame. Only the at-bottom boundary above invalidates the read acknowledgement.
|
||||
private final class AgentScrollTracking {
|
||||
var metrics = AgentScrollMetrics()
|
||||
var rowFrames: [String: CGRect] = [:]
|
||||
}
|
||||
|
||||
private struct AgentScrollMetrics: Equatable {
|
||||
var offset: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
var contentHeight: CGFloat = 0
|
||||
var bottomInset: CGFloat = 0
|
||||
var topInset: CGFloat = 0
|
||||
var visibleBottom: CGFloat = 0
|
||||
// visibleRect includes the viewport extended into the safe area, as well as
|
||||
// the alignment inset used when a short transcript is pinned to the bottom.
|
||||
var distanceFromBottom: CGFloat { contentHeight + bottomInset - visibleBottom }
|
||||
var atBottom: Bool { height > 0 && distanceFromBottom <= 24 }
|
||||
var maximumOffset: CGFloat { max(-topInset, offset + distanceFromBottom) }
|
||||
}
|
||||
private struct AgentReadingAnchor {
|
||||
var id: String
|
||||
var offset: CGFloat
|
||||
var expectedCount: Int? = nil
|
||||
}
|
||||
private struct AgentRowFrames: PreferenceKey {
|
||||
static let defaultValue: [String: CGRect] = [:]
|
||||
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
|
||||
value.merge(nextValue(), uniquingKeysWith: { _, next in next })
|
||||
}
|
||||
// Only the bottom boundary invalidates read acknowledgement, not the transcript.
|
||||
@Observable private final class AgentScrollTracking {
|
||||
var atBottom = false
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent scrolling benchmark
|
||||
# Agent interaction benchmarks
|
||||
|
||||
Run the production Agent view in an app-hosted XCTest on the iPhone 16e simulator:
|
||||
|
||||
@@ -7,62 +7,101 @@ just generate
|
||||
xcodebuild test -scheme SybilPresentation \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' \
|
||||
-derivedDataPath build/DerivedData -parallel-testing-enabled NO \
|
||||
-only-testing:SybilPresentationTests/AgentPerformanceTests \
|
||||
-resultBundlePath build/agent-performance.xcresult
|
||||
-only-testing:SybilPresentationTests/AgentPerformanceTests/testInputAndEntranceResponsiveness \
|
||||
-resultBundlePath build/agent-input-performance.xcresult
|
||||
```
|
||||
|
||||
`AgentPerformanceTests` seeds a room through its normal event reducer with 160
|
||||
local messages (100 initially displayed). User text is literal; assistant text
|
||||
contains paragraphs, lists, links, emphasis and code. The real Agent view,
|
||||
composer, avatar, entrance layout and thinking spinner remain enabled. No server,
|
||||
production credentials, notification delivery or network images are needed.
|
||||
`testInputAndEntranceResponsiveness` seeds 160 local mixed literal/Markdown
|
||||
messages through the normal room reducer (100 initially displayed), then:
|
||||
|
||||
Three approximately 12-second phases run in order: idle scrolling, thinking at
|
||||
the bottom, and thinking while scrolling. The test explicitly resets the offset
|
||||
to the bottom before the stationary thinking phase (including in the baseline).
|
||||
Thinking activity changes queue depth, worker count and tool progress every
|
||||
500 ms through the normal 100 ms publication
|
||||
batch. The display link sweeps a 2400-point range at 600 points/second, reversing
|
||||
every four seconds. Use the **same test source, simulator, build configuration
|
||||
and host load** for before/after runs. Don't run other builds or profilers during
|
||||
a measured run. Warm builds and repeat runs help distinguish stable effects from
|
||||
host noise.
|
||||
- Receives 12 more messages at 550 ms intervals through the production 100 ms
|
||||
publication batch and 240 ms entrance animation, checking bottom following.
|
||||
- Presents and dismisses the actual software keyboard six times, 600 ms per
|
||||
transition. It asserts that the transcript viewport shrinks by over 100 points
|
||||
and remains at the bottom; a hardware-keyboard-only run is invalid.
|
||||
- Calls UIKit `insertText` on the actual focused composer every 30 ms, including
|
||||
enough text to wrap and grow to its six-line limit. It verifies the draft,
|
||||
bottom following, and that keyboard/draft changes don't reproject the history.
|
||||
|
||||
The geometry assertions use iPhone 16e, iOS 26.5, and the default text size. They
|
||||
retain the unoptimized transcript heights (39181.333 points idle, 39223.333 while
|
||||
thinking), so an isolation wrapper cannot silently change row spacing. Rebaseline
|
||||
these explicitly when changing simulator/font configuration or the fixture.
|
||||
The real composer, Markdown rows, avatars and entrance animations remain enabled.
|
||||
No server, production credentials, notification delivery or network images are
|
||||
needed. The log prints `AGENT_INPUT_PERFORMANCE_JSON`; the result bundle retains
|
||||
JSON and screenshots of the actual hosted view before and after typing.
|
||||
Screenshots capture the hosting view, not the separate system keyboard window.
|
||||
|
||||
The log prints `AGENT_PERFORMANCE_JSON`; the result bundle retains the JSON as an
|
||||
attachment. Delivery-gap p50/p95/p99/max and counts over 25/50 ms measure actual
|
||||
main-run-loop responsiveness. `missedBudgetIntervals` estimates gaps relative to
|
||||
the reported display-link budget. These are **not compositor FPS, GPU hitches,
|
||||
touch latency or physical-device results**. Scrolling is programmatic; native
|
||||
swipe/anchor/read behavior needs the local-fixture UI regression tests as well.
|
||||
The hosted view excludes the surrounding app shell. Physical iPhone scrolling,
|
||||
streaming and thinking animation remain a required follow-up.
|
||||
Use the same test source, simulator, build configuration and host load for
|
||||
before/after measurements. Don't run other builds or profilers during sampling.
|
||||
Functional assertions about observation boundaries should fail against the old
|
||||
implementation; use the same sampling procedure when collecting a baseline.
|
||||
|
||||
Opt-in `AgentRenderDiagnostics` counters are compiled only in Debug and are off
|
||||
outside this test. They explain work (Agent body evaluations, message projection,
|
||||
message text construction, isolated row evaluations, geometry callbacks and
|
||||
snapshot publications). A drop in these counts alone does not establish improved
|
||||
frame responsiveness.
|
||||
## Measurement limits
|
||||
|
||||
## Reference result
|
||||
Delivery-gap p50/p95/p99/max and counts over 25/50 ms measure **CADisplayLink
|
||||
main-run-loop callback delivery**, not compositor FPS, GPU hitches, touch-to-key
|
||||
latency or physical-device performance. The hosted view excludes the surrounding
|
||||
app shell. UIKit input exercises editing/layout without synthesizing key taps.
|
||||
The first keyboard presentation includes system warmup; residual system and
|
||||
simulator scheduling gaps remain. Native gestures, read settling, header fade,
|
||||
attachments and reading anchors are covered separately by the loopback UI tests.
|
||||
|
||||
On monsoon, Xcode 26.5, Debug, iPhone 16e / iOS 26.5, clean repeat runs against
|
||||
`f534f76` plus counters and against the geometry/row isolation fix gave:
|
||||
`AgentRenderDiagnostics` counters are Debug-only and opt-in. They explain work;
|
||||
a drop in counts alone does not establish improved responsiveness.
|
||||
|
||||
| Phase | Before p95 / p99 (ms) | After p95 / p99 (ms) | Gaps >50 ms, before / after |
|
||||
The transcript now uses `LazyVStack` with the observation boundaries introduced
|
||||
in `bbaa5e8`. Loading older history may move the viewport; this is the only
|
||||
waived position guarantee. Native regressions still assert reading anchors for
|
||||
incoming messages, bottom following, keyboard behavior, read settling, fade,
|
||||
and attachments. There is no saved prepend anchor or convergence loop.
|
||||
|
||||
Lazy content height estimates change as offscreen rows materialize. The earlier
|
||||
43869→35870 point difference was not shortened content: after-arrival screenshots
|
||||
were byte-identical. Compare visible geometry and native behavior, not estimated
|
||||
whole-history height, when assessing lazy performance.
|
||||
|
||||
## Scrolling harness
|
||||
|
||||
`testThinkingScrollResponsiveness` retains the earlier three approximately
|
||||
12-second phases: idle scroll, stationary thinking, and scrolling with changing
|
||||
queue/worker/tool activity every 500 ms. Its display link sweeps 2400 points at
|
||||
600 points/second. This programmatic sweep does not reproduce native drag phases;
|
||||
use the UI tests for reading-anchor geometry and interactive keyboard dismissal.
|
||||
|
||||
The eager-stack reference in `b9a6f5d` has exact heights of 39181.333
|
||||
points idle and 39223.333 while thinking. That reference harness retains its
|
||||
eager-only total-height assertions; run it at `bbaa5e8` for the eager comparison.
|
||||
Those totals are not valid assertions about lazy estimated content size. Use the
|
||||
input harness and native gesture tests above for the current lazy implementation.
|
||||
That earlier scrolling fix reduced busy-scroll p95 from 88.8 to 19.4 ms; it did
|
||||
not address the input/entrance paths measured here. Physical phone validation
|
||||
remains necessary for hardware performance claims.
|
||||
|
||||
## 2026-10-16 comparison before the prepend waiver
|
||||
|
||||
Single Debug simulator runs on the same iPhone 16e / iOS 26.5, starting at
|
||||
`7510603` (which includes `b9a6f5d`). Values are callback-gap p95 milliseconds:
|
||||
|
||||
| Path | Before | Eager isolation (`bbaa5e8`) | Earlier lazy experiment |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Idle scroll | 50.01 / 50.02 | 16.68 / 16.71 | 128 / 0 |
|
||||
| Thinking stationary | 33.33 / 81.92 | 28.91 / 38.35 | 22 / 2 |
|
||||
| Thinking scroll | 88.78 / 90.89 | 19.41 / 30.42 | 123 / 1 |
|
||||
| 12 message entrances | 40.8 | 33.2 | 16.8 |
|
||||
| Six keyboard cycles | 33.2 | 33.1 | 29.2 |
|
||||
| Multiline composer typing | 24.8 | 25.2 | 21.3 |
|
||||
|
||||
Each phase lasted approximately 12 seconds. Content heights matched exactly.
|
||||
Busy-scroll Agent body/projection evaluations fell from 261 to 27; unchanged
|
||||
text constructions were zero in both versions. Geometry isolation alone removed
|
||||
the idle-scroll stalls; individual row isolation reduced activity-related gaps.
|
||||
These results support those two boundaries, without a Markdown cache or lazy
|
||||
layout change. Occasional simulator gaps remain; this does not replace device
|
||||
validation.
|
||||
Eager transcript projections were 12/0/0 versus 220/24/10, with no row-frame
|
||||
preference callbacks in these pinned-bottom phases. Eager content height stayed
|
||||
43869.333 points. Entrance p99 improved 83.4→57.1 ms; keyboard p99 55.9→44.5;
|
||||
typing p99 33.2→26.7. Residual gaps remain: entrance/keyboard had 10/3 gaps over
|
||||
50 ms; typing had 30 over 25 ms (baseline 19). Keyboard p95 was essentially flat
|
||||
and typing p95 did not improve. These single runs establish removed transcript
|
||||
work and a safer entrance improvement, not universally smooth input or device FPS.
|
||||
The lazy option was initially rejected because strict prepend anchors failed.
|
||||
The subsequent release accepts that prepend tradeoff; all other guarantees remain.
|
||||
|
||||
## Lazy release after the prepend waiver
|
||||
|
||||
The same input harness passes unchanged: p95 entrance/keyboard/typing gaps are
|
||||
16.9/28.1/20.9 ms, versus the eager run's 33.2/33.1/25.2 ms. Projections stay
|
||||
12/0/0; pinned row-frame preference callbacks stay zero. The after-arrival PNG
|
||||
is byte-identical to the eager baseline. Residual gaps remain (one keyboard gap
|
||||
over 50 ms and one typing gap over 25 ms); these are single simulator samples. Five
|
||||
focused native regressions pass with only the two prepend-offset assertions
|
||||
replaced by checks that the remaining history loaded.
|
||||
|
||||
@@ -72,6 +72,116 @@ final class AgentPerformanceTests: XCTestCase {
|
||||
attachment.name = "agent-performance"; attachment.lifetime = .keepAlways; add(attachment)
|
||||
}
|
||||
|
||||
/// Exercises UIKit editing/focus and the real message reducer; no synthetic
|
||||
/// view rebuild loop or replacement composer/animation is used.
|
||||
func testInputAndEntranceResponsiveness() async throws {
|
||||
let room = SybilAgentRoom()
|
||||
try room.receive(.messages(AgentPage(conversationId: "performance",
|
||||
messages: (1...160).map(Self.message), cursor: "160", beforeCursor: nil, hasMore: false)), replay: true)
|
||||
room.flush()
|
||||
let host = UIHostingController(rootView: SybilAgentView(room: room, topInset: 64)
|
||||
.background(SybilTheme.background).environment(\.colorScheme, .dark))
|
||||
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
||||
let window = UIWindow(windowScene: scene)
|
||||
window.rootViewController = host
|
||||
window.makeKeyAndVisible()
|
||||
defer {
|
||||
host.view.endEditing(true)
|
||||
AgentRenderDiagnostics.enabled = false
|
||||
window.isHidden = true; window.rootViewController = nil
|
||||
room.stop()
|
||||
}
|
||||
host.view.frame = window.bounds
|
||||
host.view.layoutIfNeeded()
|
||||
try await Task.sleep(for: .seconds(2))
|
||||
let scroll = try XCTUnwrap(findScroll(host.view))
|
||||
let input = try XCTUnwrap(findInput(host.view))
|
||||
AgentRenderDiagnostics.enabled = true
|
||||
var reports: [[String: Any]] = []
|
||||
func report(_ sampler: AgentFrameSampler, phase: String) {
|
||||
var value = sampler.stop()
|
||||
value["phase"] = phase
|
||||
value["procedure"] = "mixed160-input-entrance-v1"
|
||||
value["operations"] = AgentRenderDiagnostics.counts
|
||||
value["contentHeight"] = scroll.contentSize.height
|
||||
value["viewportHeight"] = scroll.bounds.height
|
||||
reports.append(value)
|
||||
}
|
||||
|
||||
AgentRenderDiagnostics.counts = [:]
|
||||
let entrance = AgentFrameSampler(scroll: nil)
|
||||
entrance.start()
|
||||
for index in 161...172 {
|
||||
try room.receive(.messages(AgentPage(conversationId: "performance",
|
||||
messages: [Self.message(index)], cursor: String(index), beforeCursor: nil, hasMore: false)))
|
||||
// Use the production 100 ms publication batch and 240 ms entrance.
|
||||
try await Task.sleep(for: .milliseconds(550))
|
||||
assertAtBottom(scroll)
|
||||
}
|
||||
XCTAssertLessThanOrEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 24,
|
||||
"Entrance frames must not repeatedly project the history")
|
||||
report(entrance, phase: "message-entrance")
|
||||
XCTAssertEqual(room.snapshot.room.messages.count, 172)
|
||||
assertAtBottom(scroll)
|
||||
capture(host, "input-entrance-latest")
|
||||
|
||||
let unfocusedHeight = scroll.bounds.height
|
||||
AgentRenderDiagnostics.counts = [:]
|
||||
let keyboard = AgentFrameSampler(scroll: nil)
|
||||
keyboard.start()
|
||||
for _ in 0..<6 {
|
||||
XCTAssertTrue(input.becomeFirstResponder())
|
||||
try await Task.sleep(for: .milliseconds(600))
|
||||
XCTAssertLessThan(scroll.bounds.height, unfocusedHeight - 100, "Software keyboard must actually resize the transcript")
|
||||
assertAtBottom(scroll)
|
||||
XCTAssertTrue(input.resignFirstResponder())
|
||||
try await Task.sleep(for: .milliseconds(600))
|
||||
}
|
||||
XCTAssertEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 0,
|
||||
"Keyboard focus and geometry must not project the transcript")
|
||||
report(keyboard, phase: "keyboard-presentation")
|
||||
XCTAssertEqual(scroll.bounds.height, unfocusedHeight, accuracy: 1)
|
||||
XCTAssertTrue(input.becomeFirstResponder())
|
||||
try await Task.sleep(for: .milliseconds(700))
|
||||
let keyInput = try XCTUnwrap(input as? any UIKeyInput)
|
||||
AgentRenderDiagnostics.counts = [:]
|
||||
let typing = AgentFrameSampler(scroll: nil)
|
||||
typing.start()
|
||||
let text = "Typing into the actual composer should keep the long transcript responsive as this sentence wraps across several lines. "
|
||||
for character in text + text {
|
||||
keyInput.insertText(String(character))
|
||||
try await Task.sleep(for: .milliseconds(30))
|
||||
}
|
||||
XCTAssertEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 0,
|
||||
"Draft edits and line wrapping must not project the transcript")
|
||||
assertAtBottom(scroll)
|
||||
report(typing, phase: "composer-typing")
|
||||
XCTAssertEqual(room.composer, text + text)
|
||||
capture(host, "input-multiline-keyboard")
|
||||
let data = try JSONSerialization.data(withJSONObject: reports, options: [.sortedKeys])
|
||||
print("AGENT_INPUT_PERFORMANCE_JSON " + String(decoding: data, as: UTF8.self))
|
||||
let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json")
|
||||
attachment.name = "agent-input-performance"; attachment.lifetime = .keepAlways; add(attachment)
|
||||
}
|
||||
|
||||
private func assertAtBottom(_ scroll: UIScrollView, file: StaticString = #filePath, line: UInt = #line) {
|
||||
XCTAssertEqual(scroll.contentOffset.y + scroll.bounds.height - scroll.adjustedContentInset.bottom,
|
||||
scroll.contentSize.height, accuracy: 25, file: file, line: line)
|
||||
}
|
||||
|
||||
private func findInput(_ view: UIView) -> UIView? {
|
||||
if view is UITextView || view is UITextField { return view }
|
||||
return view.subviews.lazy.compactMap { self.findInput($0) }.first
|
||||
}
|
||||
|
||||
private func capture(_ host: UIViewController, _ name: String) {
|
||||
let image = UIGraphicsImageRenderer(bounds: host.view.bounds).image { _ in
|
||||
host.view.drawHierarchy(in: host.view.bounds, afterScreenUpdates: true)
|
||||
}
|
||||
let attachment = XCTAttachment(image: image)
|
||||
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
|
||||
}
|
||||
|
||||
private static func message(_ index: Int) -> Message {
|
||||
let own = index % 3 == 0
|
||||
let content = own
|
||||
|
||||
@@ -321,15 +321,15 @@ final class AgentUITests: XCTestCase {
|
||||
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
|
||||
capture("agent-reading-anchor")
|
||||
for _ in 0..<25 {
|
||||
if app.buttons["Load earlier messages"].isHittable { break }
|
||||
if app.buttons["Load earlier messages"].exists && app.buttons["Load earlier messages"].isHittable { break }
|
||||
scroll.swipeDown(velocity: .fast)
|
||||
}
|
||||
XCTAssertTrue(app.buttons["Load earlier messages"].isHittable)
|
||||
let first = app.staticTexts["History message 61: local simulator fixture."]
|
||||
let beforeLoad = first.frame.minY
|
||||
app.buttons["Load earlier messages"].tap()
|
||||
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3)
|
||||
capture("agent-older-history-anchor")
|
||||
// Loading older lazy history may move the viewport. It must still load
|
||||
// the remaining history and preserve 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)
|
||||
composer.tap(); composer.typeText("A multiline draft\nwith a second line\nand a third line")
|
||||
|
||||
@@ -37,8 +37,8 @@ final class TranscriptFadeUITests: XCTestCase {
|
||||
}
|
||||
|
||||
private var transcript: XCUIElement {
|
||||
// The tablet sidebar is also a scroll view; select the detail by its content.
|
||||
app.scrollViews.containing(.staticText, identifier: "History message 160: local simulator fixture.").firstMatch
|
||||
// Identify the transcript independently of any particular message row.
|
||||
app.scrollViews["agent-transcript"]
|
||||
}
|
||||
|
||||
private func capture(_ name: String) {
|
||||
@@ -55,6 +55,79 @@ final class TranscriptFadeUITests: XCTestCase {
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
}
|
||||
|
||||
private func reloadMixedHistory() async throws {
|
||||
app.terminate()
|
||||
let rows = (1...160).map { index in
|
||||
let short = "History message \(index): local simulator fixture."
|
||||
return ["role": index % 3 == 0 ? "user" : "assistant",
|
||||
"content": index == 160 || index % 3 == 0 ? short :
|
||||
"### Investigation \(index)\n\n" + String(repeating: "A longer transcript paragraph with **emphasis** and `inline code`.\n\n", count: 4) +
|
||||
"- Preserve visible content.\n- Keep the composer available.\n\n```swift\nrender(messages[\(index)])\n```"]
|
||||
}
|
||||
try await control(["action": "presentation-samples", "messages": rows])
|
||||
app.launch()
|
||||
}
|
||||
|
||||
func testInitialOpenAndTabReturnShowLatestWithoutScrolling() async throws {
|
||||
try await reloadMixedHistory()
|
||||
openAgent()
|
||||
let latest = app.staticTexts["History message 160: local simulator fixture."]
|
||||
XCTAssertTrue(latest.isHittable)
|
||||
XCTAssertLessThan(latest.frame.maxY, composer.frame.minY)
|
||||
capture("agent-initial-open")
|
||||
|
||||
showSidebar()
|
||||
app.buttons["Settings"].tap()
|
||||
XCTAssertFalse(composer.exists)
|
||||
openAgent()
|
||||
XCTAssertTrue(latest.isHittable, "Returning to Agent must not need a scroll to materialize rows")
|
||||
XCTAssertLessThan(latest.frame.maxY, composer.frame.minY)
|
||||
capture("agent-tab-return")
|
||||
}
|
||||
|
||||
func testSendClearsMultilineComposerAndKeepsLatestAboveKeyboard() async throws {
|
||||
try await reloadMixedHistory()
|
||||
openAgent()
|
||||
composer.tap()
|
||||
if let existing = composer.value as? String, !existing.isEmpty, existing != "Enter Prompt" {
|
||||
composer.press(forDuration: 1)
|
||||
let selectAll = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Select All")).firstMatch
|
||||
if selectAll.waitForExistence(timeout: 2) { selectAll.tap() }
|
||||
else { composer.typeText(String(repeating: XCUIKeyboardKey.rightArrow.rawValue, count: existing.count)) }
|
||||
composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: existing.count))
|
||||
}
|
||||
let draft = "Keyboard send\nsecond line\nthird line"
|
||||
composer.typeText(draft)
|
||||
XCTAssertTrue(app.keyboards.firstMatch.exists)
|
||||
let multilineHeight = composer.frame.height
|
||||
app.buttons["agent-send"].tap()
|
||||
let cleared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == %@ OR value == %@", "", "Enter Prompt"), object: composer)
|
||||
await fulfillment(of: [cleared], timeout: 5)
|
||||
XCTAssertLessThan(composer.frame.height, multilineHeight)
|
||||
XCTAssertTrue(app.keyboards.firstMatch.exists)
|
||||
let sent = app.staticTexts[draft]
|
||||
XCTAssertTrue(sent.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(sent.isHittable)
|
||||
let reply = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: Keyboard send")).firstMatch
|
||||
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: reply)
|
||||
await fulfillment(of: [followed], timeout: 10)
|
||||
capture("agent-send-cleared-composer-with-keyboard")
|
||||
XCTAssertGreaterThan(reply.frame.minY, transcript.frame.minY + 64)
|
||||
XCTAssertLessThan(reply.frame.maxY, composer.frame.minY)
|
||||
// Finished: is only the first Markdown paragraph. Measure the final
|
||||
// reply content, leaving the same allowance for metadata and spacing.
|
||||
let replyEnd = app.staticTexts["The local fixture returned three matches."]
|
||||
XCTAssertTrue(replyEnd.isHittable)
|
||||
XCTAssertLessThan(replyEnd.frame.maxY, composer.frame.minY)
|
||||
let bottomGap = composer.frame.minY - replyEnd.frame.maxY
|
||||
print("Agent send settled final-content gap: \(bottomGap)pt")
|
||||
XCTAssertLessThan(bottomGap, 100, "Composer clearing must not leave the transcript shifted above the bottom")
|
||||
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
let read = try await control(["action": "state"])
|
||||
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
|
||||
}
|
||||
|
||||
func testSharedHeaderFadeGeometry() async throws {
|
||||
showSidebar()
|
||||
let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch
|
||||
@@ -107,23 +180,23 @@ final class TranscriptFadeUITests: XCTestCase {
|
||||
capture("agent-fade-reading-position-and-unread")
|
||||
|
||||
for _ in 0..<30 {
|
||||
if app.buttons["Load earlier messages"].isHittable { break }
|
||||
if app.buttons["Load earlier messages"].exists && app.buttons["Load earlier messages"].isHittable { break }
|
||||
scroll.swipeDown(velocity: .fast)
|
||||
}
|
||||
let load = app.buttons["Load earlier messages"]
|
||||
XCTAssertTrue(load.isHittable)
|
||||
let first = app.staticTexts["History message 61: local simulator fixture."]
|
||||
let beforeLoad = first.frame.minY
|
||||
load.tap()
|
||||
capture("agent-fade-prepend-reading-position")
|
||||
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3,
|
||||
"Prepending must preserve the readable row's position beneath the header")
|
||||
// Prepend position is intentionally not preserved with lazy history.
|
||||
// New-message reading anchors above and follow/read settling below remain strict.
|
||||
XCTAssertFalse(load.exists)
|
||||
capture("agent-fade-older-history-loaded")
|
||||
|
||||
app.buttons["agent-jump-latest"].tap()
|
||||
let latest = app.staticTexts["Fade regression reply while reading"]
|
||||
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest)
|
||||
await fulfillment(of: [followed], timeout: 5)
|
||||
composer.tap()
|
||||
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(latest.isHittable)
|
||||
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
|
||||
capture("agent-fade-latest-with-keyboard")
|
||||
@@ -134,5 +207,27 @@ final class TranscriptFadeUITests: XCTestCase {
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
let read = try await control(["action": "state"])
|
||||
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
|
||||
|
||||
// A reply still follows and settles as read while the keyboard is up.
|
||||
try await control(["action": "reply", "content": "Reply with keyboard visible"])
|
||||
let keyboardReply = app.staticTexts["Reply with keyboard visible"]
|
||||
let keyboardFollowed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: keyboardReply)
|
||||
await fulfillment(of: [keyboardFollowed], timeout: 5)
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
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.
|
||||
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() }
|
||||
XCTAssertTrue(keyboardReply.isHittable)
|
||||
composer.tap()
|
||||
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(keyboardReply.isHittable)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user