diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift index 1e1d1f9..7561ecf 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift @@ -30,7 +30,6 @@ private struct AgentTranscriptView: View { @State private var historyStartID: String? @State private var scrollPosition = ScrollPosition() @State private var scrollTracking = AgentScrollTracking() - @State private var restoreAnchor: AgentReadingAnchor? @State private var appeared = false private let bottomID = "agent-bottom" @@ -51,16 +50,12 @@ private struct AgentTranscriptView: View { let displayed = transcript.displayed 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) { + LazyVStack(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 - } + // Loading older history may move the viewport as lazy + // row estimates settle; do not chase the old position. + scrollTracking.rowFrames = [:] atLatest = false historyLimit += 100 historyStartID = allMessages[max(0, startIndex - 100)].id @@ -104,7 +99,6 @@ private struct AgentTranscriptView: View { .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, @@ -119,7 +113,7 @@ private struct AgentTranscriptView: View { 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, + if atLatest, !isScrolling, previous.height != next.height || previous.contentHeight != next.contentHeight || previous.bottomInset != next.bottomInset { proxy.scrollTo(bottomID, anchor: .bottom) } @@ -128,10 +122,10 @@ private struct AgentTranscriptView: View { #if DEBUG AgentRenderDiagnostics.record("rowPreferences") #endif - let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? visibleReadingAnchor : nil) + let anchor = !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 + // Preserve ordinary reading position after new messages 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) @@ -142,16 +136,13 @@ private struct AgentTranscriptView: View { // 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: { @@ -176,7 +167,6 @@ private struct AgentTranscriptView: View { if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) } } .onChange(of: latestRequest) { _, _ in - restoreAnchor = nil atLatest = true proxy.scrollTo(bottomID, anchor: .bottom) } @@ -407,7 +397,6 @@ private struct AgentScrollMetrics: Equatable { private struct AgentReadingAnchor { var id: String var offset: CGFloat - var expectedCount: Int? = nil } private struct AgentRowFrames: PreferenceKey { static let defaultValue: [String: CGRect] = [:] diff --git a/ios/Tests/AgentPerformance.md b/ios/Tests/AgentPerformance.md index 1f2165e..77b9262 100644 --- a/ios/Tests/AgentPerformance.md +++ b/ios/Tests/AgentPerformance.md @@ -47,18 +47,16 @@ attachments and reading anchors are covered separately by the loopback UI tests. `AgentRenderDiagnostics` counters are Debug-only and opt-in. They explain work; a drop in counts alone does not establish improved responsiveness. -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 transcript now uses `LazyVStack` with the observation boundaries introduced +in `bbaa5e8`. Loading older history may move the viewport; this is the only +waived position guarantee. Native regressions still assert reading anchors for +incoming messages, bottom following, keyboard behavior, read settling, fade, +and attachments. There is no saved prepend anchor or convergence loop. -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. +Lazy content height estimates change as offscreen rows materialize. The earlier +43869→35870 point difference was not shortened content: after-arrival screenshots +were byte-identical. Compare visible geometry and native behavior, not estimated +whole-history height, when assessing lazy performance. ## Scrolling harness @@ -69,26 +67,41 @@ queue/worker/tool activity every 500 ms. Its display link sweeps 2400 points at 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. +points idle and 39223.333 while thinking. That reference harness retains its +eager-only total-height assertions; run it at `bbaa5e8` for the eager comparison. +Those totals are not valid assertions about lazy estimated content size. Use the +input harness and native gesture tests above for the current lazy implementation. +That earlier scrolling fix reduced busy-scroll p95 from 88.8 to 19.4 ms; it did +not address the input/entrance paths measured here. Physical phone validation +remains necessary for hardware performance claims. -## 2026-10-16 focused comparison +## 2026-10-16 comparison before the prepend waiver Single Debug simulator runs on the same iPhone 16e / iOS 26.5, starting at `7510603` (which includes `b9a6f5d`). Values are callback-gap p95 milliseconds: -| Path | Before | Shipped eager isolation | Rejected lazy isolation | +| Path | Before | Eager isolation (`bbaa5e8`) | Earlier lazy experiment | | --- | ---: | ---: | ---: | | 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 | -Final transcript projections were 12/0/0 versus 220/24/10, with no row-frame +Eager transcript projections were 12/0/0 versus 220/24/10, with no row-frame preference callbacks in these pinned-bottom phases. Eager content height stayed 43869.333 points. Entrance p99 improved 83.4→57.1 ms; keyboard p99 55.9→44.5; typing p99 33.2→26.7. Residual gaps remain: entrance/keyboard had 10/3 gaps over 50 ms; typing had 30 over 25 ms (baseline 19). Keyboard p95 was essentially flat and typing p95 did not improve. These single runs establish removed transcript work and a safer entrance improvement, not universally smooth input or device FPS. -The faster lazy option was rejected because strict reading/prepend anchors failed. +The lazy option was initially rejected because strict prepend anchors failed. +The subsequent release accepts that prepend tradeoff; all other guarantees remain. + +## Lazy release after the prepend waiver + +The same input harness passes unchanged: p95 entrance/keyboard/typing gaps are +16.9/28.1/20.9 ms, versus the eager run's 33.2/33.1/25.2 ms. Projections stay +12/0/0; pinned row-frame preference callbacks stay zero. The after-arrival PNG +is byte-identical to the eager baseline. Residual gaps remain (one keyboard gap +over 50 ms and one typing gap over 25 ms); these are single simulator samples. Five +focused native regressions pass with only the two prepend-offset assertions +replaced by checks that the remaining history loaded. diff --git a/ios/Tests/SybilUITests/AgentUITests.swift b/ios/Tests/SybilUITests/AgentUITests.swift index 84625f7..64e19bb 100644 --- a/ios/Tests/SybilUITests/AgentUITests.swift +++ b/ios/Tests/SybilUITests/AgentUITests.swift @@ -325,11 +325,11 @@ final class AgentUITests: XCTestCase { scroll.swipeDown(velocity: .fast) } XCTAssertTrue(app.buttons["Load earlier messages"].isHittable) - let first = app.staticTexts["History message 61: local simulator fixture."] - let beforeLoad = first.frame.minY app.buttons["Load earlier messages"].tap() - XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3) - capture("agent-older-history-anchor") + // Loading older lazy history may move the viewport. It must still load + // the remaining history and preserve Latest/keyboard behavior below. + XCTAssertFalse(app.buttons["Load earlier messages"].exists) + capture("agent-older-history-loaded") app.buttons["agent-jump-latest"].tap() XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable) composer.tap(); composer.typeText("A multiline draft\nwith a second line\nand a third line") diff --git a/ios/Tests/SybilUITests/TranscriptFadeUITests.swift b/ios/Tests/SybilUITests/TranscriptFadeUITests.swift index 1762d88..ee8f5fa 100644 --- a/ios/Tests/SybilUITests/TranscriptFadeUITests.swift +++ b/ios/Tests/SybilUITests/TranscriptFadeUITests.swift @@ -112,12 +112,11 @@ final class TranscriptFadeUITests: XCTestCase { } let load = app.buttons["Load earlier messages"] XCTAssertTrue(load.isHittable) - let first = app.staticTexts["History message 61: local simulator fixture."] - let beforeLoad = first.frame.minY load.tap() - capture("agent-fade-prepend-reading-position") - XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3, - "Prepending must preserve the readable row's position beneath the header") + // Prepend position is intentionally not preserved with lazy history. + // New-message reading anchors above and follow/read settling below remain strict. + XCTAssertFalse(load.exists) + capture("agent-fade-older-history-loaded") app.buttons["agent-jump-latest"].tap() let latest = app.staticTexts["Fade regression reply while reading"]