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 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; 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
@FocusState private var composerFocused: Bool
@State private var atLatest = true
@State private var isScrolling = false
@State private var historyLimit = 100
@State private var historyStartID: String?
@State private var scrollPosition = ScrollPosition()
@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 }
@@ -35,149 +49,176 @@ 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
ScrollViewReader { proxy in
ScrollView {
// Exact eager geometry preserves reading/prepend offsets. Lazy
// estimates required repeated corrections and failed those anchors;
// isolate observation instead of adding a geometry feedback loop.
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
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
atLatest = false
historyLimit += 100
historyStartID = allMessages[max(0, startIndex - 100)].id
}
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)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
.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)
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in
AgentTranscriptRow(message: message, room: room, tracksReadingAnchor: !atLatest,
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)
}
}
.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)
if rendered.isTyping {
HStack(spacing: 9) {
AgentAvatar()
ProgressView().controlSize(.small)
Text("Sybil is thinking…").font(.sybil(.caption))
}
restoreAnchor = nil
} else {
scrollTracking.rowFrames = frames
if let expected = restoreAnchor?.expectedCount, frames.count >= expected { restoreAnchor = nil }
.foregroundStyle(SybilTheme.textMuted)
.accessibilityIdentifier("agent-typing")
}
Color.clear.frame(height: 1).id(bottomID)
}
.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) }
.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)
}
.accessibilityIdentifier("agent-transcript")
.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 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
composer
.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 {
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 }
.onDisappear { room.isSelected = false }
.modifier(AgentReadAcknowledgement(room: room, tracking: scrollTracking,
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) {
// 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.metrics.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.metrics.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 +240,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 +270,7 @@ struct SybilAgentView: View {
private func submitComposer() {
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
// 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, heading and whether a reading anchor is needed.
private struct AgentTranscriptRow: View, Equatable {
var message: Message
var room: SybilAgentRoom
var tracksReadingAnchor: Bool
var startsDay: Bool
nonisolated static func == (lhs: Self, rhs: Self) -> Bool {
lhs.message == rhs.message && lhs.room === rhs.room && lhs.startsDay == rhs.startsDay
&& lhs.tracksReadingAnchor == rhs.tracksReadingAnchor
}
var body: some View {
@@ -261,9 +309,15 @@ 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"))])
})
.background {
// 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
// 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] = [:]
// frame. Only the at-bottom boundary invalidates the read acknowledgement.
@Observable private final class AgentScrollTracking {
var atBottom = false
@ObservationIgnored var metrics = AgentScrollMetrics()
@ObservationIgnored var rowFrames: [String: CGRect] = [:]
}
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:
@@ -7,62 +7,88 @@ 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 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 |
| 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.
Final 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 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)
}
/// 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
+1 -1
View File
@@ -321,7 +321,7 @@ 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)
@@ -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) {
@@ -107,7 +107,7 @@ 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"]