ios: render Agent history lazily
This commit is contained in:
@@ -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] = [:]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user