ios: render Agent history lazily
TestFlight / Build and upload (push) Successful in 1m49s

This commit is contained in:
2026-10-16 12:10:29 -07:00
parent bbaa5e8859
commit d5427926d5
4 changed files with 47 additions and 46 deletions
@@ -30,7 +30,6 @@ private struct AgentTranscriptView: View {
@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 restoreAnchor: AgentReadingAnchor?
@State private var appeared = false @State private var appeared = false
private let bottomID = "agent-bottom" private let bottomID = "agent-bottom"
@@ -51,16 +50,12 @@ private struct AgentTranscriptView: View {
let displayed = transcript.displayed let displayed = transcript.displayed
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView { ScrollView {
// Exact eager geometry preserves reading/prepend offsets. Lazy LazyVStack(alignment: .leading, spacing: 15) {
// 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 { if allMessages.count > displayed.count {
Button("Load earlier messages") { Button("Load earlier messages") {
if var anchor = visibleReadingAnchor { // Loading older history may move the viewport as lazy
anchor.expectedCount = min(allMessages.count, displayed.count + 100) // row estimates settle; do not chase the old position.
restoreAnchor = anchor scrollTracking.rowFrames = [:]
}
atLatest = false atLatest = false
historyLimit += 100 historyLimit += 100
historyStartID = allMessages[max(0, startIndex - 100)].id historyStartID = allMessages[max(0, startIndex - 100)].id
@@ -104,7 +99,6 @@ private struct AgentTranscriptView: View {
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.onScrollPhaseChange { _, phase in .onScrollPhaseChange { _, phase in
isScrolling = phase.isScrolling isScrolling = phase.isScrolling
if phase == .interacting || phase == .tracking { restoreAnchor = nil }
} }
.onScrollGeometryChange(for: AgentScrollMetrics.self) { geometry in .onScrollGeometryChange(for: AgentScrollMetrics.self) { geometry in
AgentScrollMetrics(offset: geometry.contentOffset.y, AgentScrollMetrics(offset: geometry.contentOffset.y,
@@ -119,7 +113,7 @@ private struct AgentTranscriptView: View {
scrollTracking.metrics = next scrollTracking.metrics = next
if scrollTracking.atBottom != next.atBottom { scrollTracking.atBottom = next.atBottom } if scrollTracking.atBottom != next.atBottom { scrollTracking.atBottom = next.atBottom }
if isScrolling || next.contentHeight <= next.height { atLatest = 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 { previous.height != next.height || previous.contentHeight != next.contentHeight || previous.bottomInset != next.bottomInset {
proxy.scrollTo(bottomID, anchor: .bottom) proxy.scrollTo(bottomID, anchor: .bottom)
} }
@@ -128,10 +122,10 @@ private struct AgentTranscriptView: View {
#if DEBUG #if DEBUG
AgentRenderDiagnostics.record("rowPreferences") AgentRenderDiagnostics.record("rowPreferences")
#endif #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 { if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
// Restore the exact pixel offset after insertion or text // Preserve ordinary reading position after new messages or
// reflow. Normalize the saved frames to // text reflow. Normalize the saved frames to
// the requested offset so the next layout cannot undo it. // the requested offset so the next layout cannot undo it.
let requested = scrollTracking.metrics.offset + frame.minY - anchor.offset let requested = scrollTracking.metrics.offset + frame.minY - anchor.offset
let target = min(max(requested, -scrollTracking.metrics.topInset), scrollTracking.metrics.maximumOffset) 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. // zero at the top inset, including the safe area.
scrollPosition.scrollTo(y: target + scrollTracking.metrics.topInset) scrollPosition.scrollTo(y: target + scrollTracking.metrics.topInset)
} }
restoreAnchor = nil
} else { } else {
scrollTracking.rowFrames = frames scrollTracking.rowFrames = frames
if let expected = restoreAnchor?.expectedCount, frames.count >= expected { restoreAnchor = nil }
} }
} }
.overlay(alignment: .bottomTrailing) { .overlay(alignment: .bottomTrailing) {
if !atLatest { if !atLatest {
Button { Button {
restoreAnchor = nil
atLatest = true atLatest = true
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(bottomID, anchor: .bottom) } withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(bottomID, anchor: .bottom) }
} label: { } label: {
@@ -176,7 +167,6 @@ private struct AgentTranscriptView: View {
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) } if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
} }
.onChange(of: latestRequest) { _, _ in .onChange(of: latestRequest) { _, _ in
restoreAnchor = nil
atLatest = true atLatest = true
proxy.scrollTo(bottomID, anchor: .bottom) proxy.scrollTo(bottomID, anchor: .bottom)
} }
@@ -407,7 +397,6 @@ private struct AgentScrollMetrics: Equatable {
private struct AgentReadingAnchor { private struct AgentReadingAnchor {
var id: String var id: String
var offset: CGFloat var offset: CGFloat
var expectedCount: Int? = nil
} }
private struct AgentRowFrames: PreferenceKey { private struct AgentRowFrames: PreferenceKey {
static let defaultValue: [String: CGRect] = [:] static let defaultValue: [String: CGRect] = [:]
+31 -18
View File
@@ -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; `AgentRenderDiagnostics` counters are Debug-only and opt-in. They explain work;
a drop in counts alone does not establish improved responsiveness. a drop in counts alone does not establish improved responsiveness.
The shipped transcript retains its eager stack and exact row geometry. A lazy The transcript now uses `LazyVStack` with the observation boundaries introduced
experiment reduced measured work but failed the existing 2–3 point prepend in `bbaa5e8`. Loading older history may move the viewport; this is the only
anchor checks. Retaining a pending anchor across geometry updates also failed; waived position guarantee. Native regressions still assert reading anchors for
an indefinite correction loop is not acceptable. The eager fallback keeps the incoming messages, bottom following, keyboard behavior, read settling, fade,
existing one-shot correction, isolates composer/read observation, and removes and attachments. There is no saved prepend anchor or convergence loop.
row frame observers while following the bottom.
The experimental lazy content height changed from 43869 to 35870 points because Lazy content height estimates change as offscreen rows materialize. The earlier
unmaterialized rows use estimates, not because content was shortened. Its 43869→35870 point difference was not shortened content: after-arrival screenshots
after-arrival screenshot was byte-identical to the eager baseline. Row text, were byte-identical. Compare visible geometry and native behavior, not estimated
spacing, attachments and entrance growth were unchanged; the anchor failure whole-history height, when assessing lazy performance.
was sufficient to reject the lazy variant.
## Scrolling harness ## 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. use the UI tests for reading-anchor geometry and interactive keyboard dismissal.
The eager-stack reference in `b9a6f5d` has exact heights of 39181.333 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 points idle and 39223.333 while thinking. That reference harness retains its
19.4 ms; it did not address the input/entrance paths measured here. Physical eager-only total-height assertions; run it at `bbaa5e8` for the eager comparison.
phone validation remains necessary for hardware performance claims. 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 Single Debug simulator runs on the same iPhone 16e / iOS 26.5, starting at
`7510603` (which includes `b9a6f5d`). Values are callback-gap p95 milliseconds: `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 | | 12 message entrances | 40.8 | 33.2 | 16.8 |
| Six keyboard cycles | 33.2 | 33.1 | 29.2 | | Six keyboard cycles | 33.2 | 33.1 | 29.2 |
| Multiline composer typing | 24.8 | 25.2 | 21.3 | | 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 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; 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 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 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 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. 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.
+4 -4
View File
@@ -325,11 +325,11 @@ final class AgentUITests: XCTestCase {
scroll.swipeDown(velocity: .fast) scroll.swipeDown(velocity: .fast)
} }
XCTAssertTrue(app.buttons["Load earlier messages"].isHittable) 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() app.buttons["Load earlier messages"].tap()
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3) // Loading older lazy history may move the viewport. It must still load
capture("agent-older-history-anchor") // 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() app.buttons["agent-jump-latest"].tap()
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable) XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
composer.tap(); composer.typeText("A multiline draft\nwith a second line\nand a third line") composer.tap(); composer.typeText("A multiline draft\nwith a second line\nand a third line")
@@ -112,12 +112,11 @@ final class TranscriptFadeUITests: XCTestCase {
} }
let load = app.buttons["Load earlier messages"] let load = app.buttons["Load earlier messages"]
XCTAssertTrue(load.isHittable) XCTAssertTrue(load.isHittable)
let first = app.staticTexts["History message 61: local simulator fixture."]
let beforeLoad = first.frame.minY
load.tap() load.tap()
capture("agent-fade-prepend-reading-position") // Prepend position is intentionally not preserved with lazy history.
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3, // New-message reading anchors above and follow/read settling below remain strict.
"Prepending must preserve the readable row's position beneath the header") XCTAssertFalse(load.exists)
capture("agent-fade-older-history-loaded")
app.buttons["agent-jump-latest"].tap() app.buttons["agent-jump-latest"].tap()
let latest = app.staticTexts["Fade regression reply while reading"] let latest = app.staticTexts["Fade regression reply while reading"]