ios: isolate Agent input and read observation

This commit is contained in:
2026-10-16 11:51:07 -07:00
parent 7510603b77
commit bbaa5e8859
5 changed files with 390 additions and 199 deletions
@@ -1,25 +1,39 @@
import SwiftUI import SwiftUI
import Observation
struct SybilAgentView: View { struct SybilAgentView: View {
@Bindable var room: SybilAgentRoom var room: SybilAgentRoom
var topInset: CGFloat = 0 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; resizing is handled by its scroll geometry.
private struct AgentTranscriptView: View {
var room: SybilAgentRoom
var topInset: CGFloat
var latestRequest: Int
@Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityReduceMotion) private var reduceMotion
@FocusState private var composerFocused: Bool
@State private var atLatest = true @State private var atLatest = true
@State private var isScrolling = false @State private var isScrolling = false
@State private var historyLimit = 100 @State private var historyLimit = 100
@State private var historyStartID: String? @State private var historyStartID: String?
@State private var scrollPosition = ScrollPosition() @State private var scrollPosition = ScrollPosition()
@State private var scrollTracking = AgentScrollTracking() @State private var scrollTracking = AgentScrollTracking()
@State private var atBottom = false
@State private var restoreAnchor: AgentReadingAnchor? @State private var restoreAnchor: AgentReadingAnchor?
@State private var appeared = false @State private var appeared = false
private let bottomID = "agent-bottom" 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? { private var visibleReadingAnchor: AgentReadingAnchor? {
scrollTracking.rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollTracking.metrics.height } scrollTracking.rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollTracking.metrics.height }
.min { $0.value.minY < $1.value.minY } .min { $0.value.minY < $1.value.minY }
@@ -35,149 +49,176 @@ struct SybilAgentView: View {
let allMessages = transcript.messages let allMessages = transcript.messages
let startIndex = transcript.startIndex let startIndex = transcript.startIndex
let displayed = transcript.displayed let displayed = transcript.displayed
VStack(spacing: 0) { ScrollViewReader { proxy in
ScrollViewReader { proxy in ScrollView {
ScrollView { // Exact eager geometry preserves reading/prepend offsets. Lazy
VStack(alignment: .leading, spacing: 15) { // estimates required repeated corrections and failed those anchors;
if allMessages.count > displayed.count { // isolate observation instead of adding a geometry feedback loop.
Button("Load earlier messages") { VStack(alignment: .leading, spacing: 15) {
if var anchor = visibleReadingAnchor { if allMessages.count > displayed.count {
anchor.expectedCount = min(allMessages.count, displayed.count + 100) Button("Load earlier messages") {
restoreAnchor = anchor if var anchor = visibleReadingAnchor {
} anchor.expectedCount = min(allMessages.count, displayed.count + 100)
atLatest = false restoreAnchor = anchor
historyLimit += 100
historyStartID = allMessages[max(0, startIndex - 100)].id
} }
.frame(maxWidth: .infinity) atLatest = false
.padding(.vertical, 10) historyLimit += 100
historyStartID = allMessages[max(0, startIndex - 100)].id
} }
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) } .frame(maxWidth: .infinity)
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in .padding(.vertical, 10)
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)
} }
.scrollTargetLayout() if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
.padding(.horizontal, 16) ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in
.padding(.bottom, 16) AgentTranscriptRow(message: message, room: room, tracksReadingAnchor: !atLatest,
// Match normal chat: content clears the header, while the startsDay: index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt))
// viewport extends beneath the shared navigation fade. .equatable()
.padding(.top, 16 + topInset) .modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
.frame(maxWidth: SybilLayout.webContentMaxWidth) .id(message.id)
.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)
} }
} if rendered.isTyping {
.onPreferenceChange(AgentRowFrames.self) { frames in HStack(spacing: 9) {
#if DEBUG AgentAvatar()
AgentRenderDiagnostics.record("rowPreferences") ProgressView().controlSize(.small)
#endif Text("Sybil is thinking…").font(.sybil(.caption))
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 .foregroundStyle(SybilTheme.textMuted)
} else { .accessibilityIdentifier("agent-typing")
scrollTracking.rowFrames = frames
if let expected = restoreAnchor?.expectedCount, frames.count >= expected { restoreAnchor = nil }
} }
Color.clear.frame(height: 1).id(bottomID)
} }
.overlay(alignment: .bottomTrailing) { .scrollTargetLayout()
if !atLatest { .padding(.horizontal, 16)
Button { .padding(.bottom, 16)
atLatest = true // Match normal chat: content clears the header, while the
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(bottomID, anchor: .bottom) } // viewport extends beneath the shared navigation fade.
} label: { .padding(.top, 16 + topInset)
Label(room.unreadCount > 0 ? "\(room.unreadCount) unread" : "Latest", systemImage: "arrow.down") .frame(maxWidth: SybilLayout.webContentMaxWidth)
.font(.sybil(.caption, weight: .semibold)) .frame(maxWidth: .infinity)
.padding(12) }
.background(SybilTheme.surfaceStrong, in: Capsule()) .accessibilityIdentifier("agent-transcript")
.overlay(Capsule().stroke(SybilTheme.border)) .coordinateSpace(name: "agent-scroll")
} .scrollPosition($scrollPosition)
.padding(14) .defaultScrollAnchor(.bottom, for: .initialOffset)
.accessibilityIdentifier("agent-jump-latest") .defaultScrollAnchor(.bottom, for: .alignment)
} .scrollDismissesKeyboard(.interactively)
} .onScrollPhaseChange { _, phase in
.onAppear { isScrolling = phase.isScrolling
appeared = true if phase == .interacting || phase == .tracking { restoreAnchor = nil }
if !room.isLoading { historyStartID = displayed.first?.id } }
} .onScrollGeometryChange(for: AgentScrollMetrics.self) { geometry in
.onChange(of: room.isLoading) { _, loading in AgentScrollMetrics(offset: geometry.contentOffset.y,
if !loading && historyStartID == nil { historyStartID = displayed.first?.id } height: geometry.containerSize.height,
} contentHeight: geometry.contentSize.height,
.onChange(of: allMessages.count) { _, _ in bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top,
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) } visibleBottom: geometry.visibleRect.maxY)
} } action: { previous, next in
.onChange(of: composerFocused) { _, _ in #if DEBUG
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) } AgentRenderDiagnostics.record("scrollGeometry")
#endif
scrollTracking.metrics = next
if scrollTracking.atBottom != next.atBottom { scrollTracking.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)
} }
} }
statusBar .onPreferenceChange(AgentRowFrames.self) { frames in
composer #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 {
restoreAnchor = nil
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: latestRequest) { _, _ in
restoreAnchor = nil
atLatest = true
proxy.scrollTo(bottomID, anchor: .bottom)
}
} }
.onAppear { room.isSelected = true } .modifier(AgentReadAcknowledgement(room: room, tracking: scrollTracking,
.onDisappear { room.isSelected = false } atLatest: atLatest, isScrolling: isScrolling))
}
}
// 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) { .task(id: readKey) {
// This cursor belongs to the rendered snapshot. New network input cancels // Capture the rendered cursor before scheduling. New network input
// the task, and cannot be acknowledged while still entering the layout. // cancels this task and must settle in layout before acknowledgement.
let cursor = rendered.room.cursor guard atLatest, tracking.metrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
guard atLatest, scrollTracking.metrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360)) 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.metrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
room.markRead(renderedCursor: cursor) 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 let activity = room.snapshot.room.activity
if let error = room.error ?? activity.error { if let error = room.error ?? activity.error {
HStack(alignment: .top) { HStack(alignment: .top) {
@@ -199,12 +240,18 @@ struct SybilAgentView: View {
.padding(.horizontal, 16).padding(.vertical, 6) .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 { private var canSendComposer: Bool {
room.attachmentDraft.canSend room.attachmentDraft.canSend
} }
private var composer: some View { var body: some View {
AgentAttachmentComposer(room: room) { AgentAttachmentComposer(room: room) {
SybilPromptField( SybilPromptField(
text: $room.composer, text: $room.composer,
@@ -223,7 +270,7 @@ struct SybilAgentView: View {
private func submitComposer() { private func submitComposer() {
guard canSendComposer, room.sendComposer() else { return } guard canSendComposer, room.sendComposer() else { return }
atLatest = true onSubmit()
} }
} }
@@ -236,16 +283,17 @@ struct AgentAvatar: View {
} }
} }
// Activity/read publications still update the surrounding view. Completed rows // Activity/read publications still update the transcript. Completed rows depend
// depend only on their message values and date heading, so progress changes // only on message values, heading and whether a reading anchor is needed.
// must not rebuild the eager history (whose exact heights preserve anchors).
private struct AgentTranscriptRow: View, Equatable { private struct AgentTranscriptRow: View, Equatable {
var message: Message var message: Message
var room: SybilAgentRoom var room: SybilAgentRoom
var tracksReadingAnchor: Bool
var startsDay: Bool var startsDay: Bool
nonisolated static func == (lhs: Self, rhs: Self) -> Bool { nonisolated static func == (lhs: Self, rhs: Self) -> Bool {
lhs.message == rhs.message && lhs.room === rhs.room && lhs.startsDay == rhs.startsDay lhs.message == rhs.message && lhs.room === rhs.room && lhs.startsDay == rhs.startsDay
&& lhs.tracksReadingAnchor == rhs.tracksReadingAnchor
} }
var body: some View { var body: some View {
@@ -261,9 +309,15 @@ private struct AgentTranscriptRow: View, Equatable {
.padding(.vertical, 6) .padding(.vertical, 6)
} }
AgentMessageRow(message: message, room: room) AgentMessageRow(message: message, room: room)
.background(GeometryReader { geometry in .background {
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))]) // Pinned-bottom layout needs only the scroll view's metrics.
}) // Rows report frames only while preserving a reading anchor.
if tracksReadingAnchor {
GeometryReader { geometry in
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
}
}
}
} }
} }
} }
@@ -330,10 +384,11 @@ private struct AgentEntrance: ViewModifier {
// Pixel geometry is imperative anchor bookkeeping, not rendered content. Keeping // Pixel geometry is imperative anchor bookkeeping, not rendered content. Keeping
// it outside observation avoids rebuilding the entire transcript on every scroll // it outside observation avoids rebuilding the entire transcript on every scroll
// frame. Only the at-bottom boundary above invalidates the read acknowledgement. // frame. Only the at-bottom boundary invalidates the read acknowledgement.
private final class AgentScrollTracking { @Observable private final class AgentScrollTracking {
var metrics = AgentScrollMetrics() var atBottom = false
var rowFrames: [String: CGRect] = [:] @ObservationIgnored var metrics = AgentScrollMetrics()
@ObservationIgnored var rowFrames: [String: CGRect] = [:]
} }
private struct AgentScrollMetrics: Equatable { private struct AgentScrollMetrics: Equatable {
+75 -49
View File
@@ -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: Run the production Agent view in an app-hosted XCTest on the iPhone 16e simulator:
@@ -7,62 +7,88 @@ just generate
xcodebuild test -scheme SybilPresentation \ xcodebuild test -scheme SybilPresentation \
-destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' \ -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' \
-derivedDataPath build/DerivedData -parallel-testing-enabled NO \ -derivedDataPath build/DerivedData -parallel-testing-enabled NO \
-only-testing:SybilPresentationTests/AgentPerformanceTests \ -only-testing:SybilPresentationTests/AgentPerformanceTests/testInputAndEntranceResponsiveness \
-resultBundlePath build/agent-performance.xcresult -resultBundlePath build/agent-input-performance.xcresult
``` ```
`AgentPerformanceTests` seeds a room through its normal event reducer with 160 `testInputAndEntranceResponsiveness` seeds 160 local mixed literal/Markdown
local messages (100 initially displayed). User text is literal; assistant text messages through the normal room reducer (100 initially displayed), then:
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.
Three approximately 12-second phases run in order: idle scrolling, thinking at - Receives 12 more messages at 550 ms intervals through the production 100 ms
the bottom, and thinking while scrolling. The test explicitly resets the offset publication batch and 240 ms entrance animation, checking bottom following.
to the bottom before the stationary thinking phase (including in the baseline). - Presents and dismisses the actual software keyboard six times, 600 ms per
Thinking activity changes queue depth, worker count and tool progress every transition. It asserts that the transcript viewport shrinks by over 100 points
500 ms through the normal 100 ms publication and remains at the bottom; a hardware-keyboard-only run is invalid.
batch. The display link sweeps a 2400-point range at 600 points/second, reversing - Calls UIKit `insertText` on the actual focused composer every 30 ms, including
every four seconds. Use the **same test source, simulator, build configuration enough text to wrap and grow to its six-line limit. It verifies the draft,
and host load** for before/after runs. Don't run other builds or profilers during bottom following, and that keyboard/draft changes don't reproject the history.
a measured run. Warm builds and repeat runs help distinguish stable effects from
host noise.
The geometry assertions use iPhone 16e, iOS 26.5, and the default text size. They The real composer, Markdown rows, avatars and entrance animations remain enabled.
retain the unoptimized transcript heights (39181.333 points idle, 39223.333 while No server, production credentials, notification delivery or network images are
thinking), so an isolation wrapper cannot silently change row spacing. Rebaseline needed. The log prints `AGENT_INPUT_PERFORMANCE_JSON`; the result bundle retains
these explicitly when changing simulator/font configuration or the fixture. 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 Use the same test source, simulator, build configuration and host load for
attachment. Delivery-gap p50/p95/p99/max and counts over 25/50 ms measure actual before/after measurements. Don't run other builds or profilers during sampling.
main-run-loop responsiveness. `missedBudgetIntervals` estimates gaps relative to Functional assertions about observation boundaries should fail against the old
the reported display-link budget. These are **not compositor FPS, GPU hitches, implementation; use the same sampling procedure when collecting a baseline.
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.
Opt-in `AgentRenderDiagnostics` counters are compiled only in Debug and are off ## Measurement limits
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.
## 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 `AgentRenderDiagnostics` counters are Debug-only and opt-in. They explain work;
`f534f76` plus counters and against the geometry/row isolation fix gave: 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 shipped transcript retains its eager stack and exact row geometry. A lazy
experiment reduced measured work but failed the existing 2–3 point prepend
anchor checks. Retaining a pending anchor across geometry updates also failed;
an indefinite correction loop is not acceptable. The eager fallback keeps the
existing one-shot correction, isolates composer/read observation, and removes
row frame observers while following the bottom.
The experimental lazy content height changed from 43869 to 35870 points because
unmaterialized rows use estimates, not because content was shortened. Its
after-arrival screenshot was byte-identical to the eager baseline. Row text,
spacing, attachments and entrance growth were unchanged; the anchor failure
was sufficient to reject the lazy variant.
## 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, retained as regression assertions. 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 focused comparison
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 | Shipped eager isolation | Rejected lazy isolation |
| --- | ---: | ---: | ---: | | --- | ---: | ---: | ---: |
| Idle scroll | 50.01 / 50.02 | 16.68 / 16.71 | 128 / 0 | | 12 message entrances | 40.8 | 33.2 | 16.8 |
| Thinking stationary | 33.33 / 81.92 | 28.91 / 38.35 | 22 / 2 | | Six keyboard cycles | 33.2 | 33.1 | 29.2 |
| Thinking scroll | 88.78 / 90.89 | 19.41 / 30.42 | 123 / 1 | | Multiline composer typing | 24.8 | 25.2 | 21.3 |
Each phase lasted approximately 12 seconds. Content heights matched exactly. Final transcript projections were 12/0/0 versus 220/24/10, with no row-frame
Busy-scroll Agent body/projection evaluations fell from 261 to 27; unchanged preference callbacks in these pinned-bottom phases. Eager content height stayed
text constructions were zero in both versions. Geometry isolation alone removed 43869.333 points. Entrance p99 improved 83.4→57.1 ms; keyboard p99 55.9→44.5;
the idle-scroll stalls; individual row isolation reduced activity-related gaps. typing p99 33.2→26.7. Residual gaps remain: entrance/keyboard had 10/3 gaps over
These results support those two boundaries, without a Markdown cache or lazy 50 ms; typing had 30 over 25 ms (baseline 19). Keyboard p95 was essentially flat
layout change. Occasional simulator gaps remain; this does not replace device and typing p95 did not improve. These single runs establish removed transcript
validation. work and a safer entrance improvement, not universally smooth input or device FPS.
The faster lazy option was rejected because strict reading/prepend anchors failed.
@@ -72,6 +72,116 @@ final class AgentPerformanceTests: XCTestCase {
attachment.name = "agent-performance"; attachment.lifetime = .keepAlways; add(attachment) 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 { private static func message(_ index: Int) -> Message {
let own = index % 3 == 0 let own = index % 3 == 0
let content = own let content = own
+1 -1
View File
@@ -321,7 +321,7 @@ final class AgentUITests: XCTestCase {
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2) XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
capture("agent-reading-anchor") capture("agent-reading-anchor")
for _ in 0..<25 { 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) scroll.swipeDown(velocity: .fast)
} }
XCTAssertTrue(app.buttons["Load earlier messages"].isHittable) XCTAssertTrue(app.buttons["Load earlier messages"].isHittable)
@@ -37,8 +37,8 @@ final class TranscriptFadeUITests: XCTestCase {
} }
private var transcript: XCUIElement { private var transcript: XCUIElement {
// The tablet sidebar is also a scroll view; select the detail by its content. // Identify the transcript independently of any particular message row.
app.scrollViews.containing(.staticText, identifier: "History message 160: local simulator fixture.").firstMatch app.scrollViews["agent-transcript"]
} }
private func capture(_ name: String) { private func capture(_ name: String) {
@@ -107,7 +107,7 @@ final class TranscriptFadeUITests: XCTestCase {
capture("agent-fade-reading-position-and-unread") capture("agent-fade-reading-position-and-unread")
for _ in 0..<30 { 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) scroll.swipeDown(velocity: .fast)
} }
let load = app.buttons["Load earlier messages"] let load = app.buttons["Load earlier messages"]