From bbaa5e88598768c6a6602a89c86cda2109674cac Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 16 Oct 2026 11:51:07 -0700 Subject: [PATCH] ios: isolate Agent input and read observation --- .../Sybil/Sources/Sybil/SybilAgentView.swift | 347 ++++++++++-------- ios/Tests/AgentPerformance.md | 124 ++++--- .../AgentPerformanceTests.swift | 110 ++++++ ios/Tests/SybilUITests/AgentUITests.swift | 2 +- .../SybilUITests/TranscriptFadeUITests.swift | 6 +- 5 files changed, 390 insertions(+), 199 deletions(-) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift index 4dca7aa..1e1d1f9 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift @@ -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 { diff --git a/ios/Tests/AgentPerformance.md b/ios/Tests/AgentPerformance.md index ba45668..1f2165e 100644 --- a/ios/Tests/AgentPerformance.md +++ b/ios/Tests/AgentPerformance.md @@ -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. diff --git a/ios/Tests/SybilPresentationTests/AgentPerformanceTests.swift b/ios/Tests/SybilPresentationTests/AgentPerformanceTests.swift index ce67e2e..16b5e86 100644 --- a/ios/Tests/SybilPresentationTests/AgentPerformanceTests.swift +++ b/ios/Tests/SybilPresentationTests/AgentPerformanceTests.swift @@ -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 diff --git a/ios/Tests/SybilUITests/AgentUITests.swift b/ios/Tests/SybilUITests/AgentUITests.swift index a7d7dd9..84625f7 100644 --- a/ios/Tests/SybilUITests/AgentUITests.swift +++ b/ios/Tests/SybilUITests/AgentUITests.swift @@ -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) diff --git a/ios/Tests/SybilUITests/TranscriptFadeUITests.swift b/ios/Tests/SybilUITests/TranscriptFadeUITests.swift index 141c75e..1762d88 100644 --- a/ios/Tests/SybilUITests/TranscriptFadeUITests.swift +++ b/ios/Tests/SybilUITests/TranscriptFadeUITests.swift @@ -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"]