Skip to content

Commit d9cbdaf

Browse files
committed
Merge task 1387 iOS transcript history scroll
2 parents 6fd577c + 74e6569 commit d9cbdaf

9 files changed

Lines changed: 691 additions & 51 deletions
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
# TASK-1387 iOS Transcript History Scroll Stability
2+
3+
## Scope
4+
5+
This is an iOS-only fix for chat transcript history expansion in
6+
`mobile/garyx-mobile`. It does not change desktop behavior, the gateway event
7+
stream contract, or server-side render-state derivation.
8+
9+
The invariant stays unchanged:
10+
11+
- SSE `events` remain gapless by `after_seq`.
12+
- `render_state.rows` may be narrowed by a render floor.
13+
- `render_state.based_on_seq` remains the committed tail.
14+
- iOS dumb-renders server `render_state.rows`; it does not regroup turns, pair
15+
tools, place final answers, or derive tail thinking.
16+
17+
## Reproduction
18+
19+
The red SwiftPM tests added for this task cover the current unstable paths:
20+
21+
- `GaryxTranscriptSyncPlannerTests.testCapturedOneTurnInitialWindowRequiresLargerMobileDefault`
22+
decodes a scrubbed structural capture of the live local
23+
`thread_render_frame` shape. With `initial_user_turns=1`, the first frames had
24+
one visible `user_turn` row and `window.has_more_above=true`, which means the
25+
top boundary is immediately materialized.
26+
- `GaryxConversationScrollStateTests.testCapturedSmallWindowDoesNotAutoPrefetchAfterLightScroll`
27+
shows that a barely scrollable one-turn window can pass the current automatic
28+
prefetch gate after a small user scroll.
29+
- `GaryxConversationScrollStateTests.testTopRowAppearanceStillHonorsDistanceFromLoadedHistoryStart`
30+
shows that top-row `onAppear` currently bypasses the distance gate with
31+
`ignoreDistance=true`.
32+
- `GaryxHistoryPaginationPlannerTests` intentionally fails to compile because
33+
stable history pagination is still app-local. The missing Core seam is the bug
34+
boundary for `hasMoreHistoryBefore` flicker and cache-hit idempotence.
35+
- `GaryxConversationScrollStateTests.testRenderRowPrependPreservesScrollWhenMessagesDidNotChange`
36+
drives two server render snapshots through `GaryxMobileRenderStateMapper`.
37+
The transcript cache rows are unchanged, but the render row ids prepend when
38+
the floor drops, proving `onChange(messages)` alone cannot observe the
39+
visible insertion.
40+
- Existing window/prefetch tests have been updated to the same intended
41+
contract, so the suite no longer has contradictory old expectations for
42+
`initial_user_turns=1` or `ignoreDistance=true`.
43+
44+
No raw local thread id, message text, personal path, token, or user data is
45+
committed. The capture in tests keeps only public-safe frame structure.
46+
47+
## Design
48+
49+
### 1. Make the cold window large enough
50+
51+
Change `GaryxThreadWindowPlanner.initialUserTurns` from `1` to `3`.
52+
53+
Reasoning:
54+
55+
- The existing HTTP open path already uses `threadHistoryUserQueryLimit = 3`.
56+
- The current one-turn SSE cold window is the only intentionally tiny window.
57+
- This is the narrowest change because `initial_user_turns` is client-declared
58+
in `GaryxThreadWindowPlanner`; the gateway already accepts the parameter.
59+
- Floor/gapless semantics are unchanged. The client asks for a larger initial
60+
row window, then reconnects with the server-provided floor exactly as before.
61+
- The existing
62+
`GaryxTranscriptSyncPlannerTests.testThreadWindowPlannerColdReconnectScrollUpSequence`
63+
assertion changes from `1` to `3`; gateway tests that explicitly pass
64+
`initial_user_turns=1` as a fixture are not changed.
65+
66+
### 2. Stop treating top-row appearance as permission to fetch
67+
68+
Keep top-row `onAppear` as a hint, but route it through the same scroll-distance
69+
policy as metrics changes. Remove the `ignoreDistance` parameter from automatic
70+
prefetch entirely:
71+
72+
- `GaryxLoadEarlierHistoryButton.onAppear`
73+
- `GaryxMobileTurnRowsView.onNearHistoryBoundary`
74+
75+
Tighten `GaryxConversationScrollState.shouldPrefetchOlderHistory` so automatic
76+
prefetch has this exact signature and requires every gate below:
77+
78+
```swift
79+
shouldPrefetchOlderHistory(
80+
hasMoreHistoryBefore: Bool,
81+
isLoadingOlderHistory: Bool,
82+
hasPendingPrefetch: Bool
83+
) -> Bool
84+
```
85+
86+
- `hasMoreHistoryBefore`
87+
- not already loading
88+
- no pending prefetch
89+
- a real user move toward older history
90+
- measured scrollable content larger than a tiny cold window: compute
91+
`contentHeight = contentBottomOffset - contentTopOffset` and require
92+
`contentHeight - viewportHeight >= max(640, viewportHeight)`
93+
- proximity to the loaded history start
94+
(`contentTopOffset >= -max(640, viewportHeight * 1.5)`)
95+
96+
The button tap remains the explicit manual path and can call
97+
`loadOlderSelectedThreadHistory()` directly.
98+
99+
Expected policy examples with an 800pt viewport:
100+
101+
- `contentTopOffset=-80`, `contentBottomOffset=980` is rejected even though it
102+
is near the loaded start, because overflow is only 260pt.
103+
- `contentTopOffset=-2000`, `contentBottomOffset=2600` is rejected because it is
104+
not near the loaded start.
105+
- `contentTopOffset=-400`, `contentBottomOffset=2600` is accepted after a real
106+
user move, because overflow is 2200pt and the loaded start is within the
107+
1200pt prefetch band.
108+
109+
This intentionally updates the old
110+
`testHistoryPrefetchRequiresMovementAndProximity` case that asserted
111+
`top=-2000` could still prefetch through `ignoreDistance=true`. That assertion
112+
encoded the deleted bypass behavior and must become false under the new
113+
contract. The positive automatic prefetch example is now the `-400/2600/800`
114+
case above.
115+
116+
### 3. Move history pagination truth into Core
117+
118+
Add a Core planner:
119+
120+
- `GaryxHistoryPaginationState`
121+
- `GaryxHistoryPaginationPage`
122+
- `GaryxHistoryPaginationPlanner`
123+
124+
It has two inputs:
125+
126+
- HTTP/cache pages, which are authoritative for the cached older boundary.
127+
- render-window snapshots, which can seed or lower the render floor but must not
128+
clear a cached older boundary just because one transient frame says
129+
`has_more_above=false`.
130+
131+
Rules:
132+
133+
- A render window with `has_more_above=true` and `floor_seq > 1` yields
134+
`hasMoreBefore=true`, `nextBeforeIndex=floor_seq - 1`.
135+
- A render window with `has_more_above=false` clears pagination only when the
136+
cached page state is present and also says there is no older boundary. Missing
137+
cache truth preserves the current boundary instead of treating "unknown" as
138+
"no older page".
139+
- The clear path is tested: when both render window and cached state say no
140+
older boundary, `hasMoreBefore=false` and `nextBeforeIndex=nil`, so the
141+
"Load earlier" affordance disappears at the true top.
142+
- A cached older page with the same `nextBeforeIndex` is idempotent, so
143+
automatic triggers do not repeatedly request the same page after a cache hit
144+
or duplicate server response.
145+
146+
Wire this planner into:
147+
148+
- `applyRenderWindowPagination`
149+
- `updateSelectedThreadHistoryPagination`
150+
151+
The planner owns only `hasMoreBefore` and `nextBeforeIndex`. It does not own
152+
`selectedThreadRenderFloorByThread`, `render_floor` requests, event cursors, or
153+
`based_on_seq`; those remain in the existing stream/history code paths.
154+
155+
### 4. Preserve scroll for render-row prepends, not only message prepends
156+
157+
Older-page flow currently prepends bodies, lowers `render_floor`, then restarts
158+
SSE. The visible row insertion may happen on the later render snapshot, while
159+
`onChange(of: model.messages)` already fired. Add a view-level row-id change
160+
hook backed by a Core method:
161+
162+
- track `selectedThreadTurnRows().map(\.id)`
163+
- call
164+
`GaryxConversationScrollState.renderRowsChanged(previousIds:currentIds:threadUnchanged:hasTailContent:)`
165+
- the Core method uses the existing prepend detector and feeds
166+
`contentChanged(isHistoryPrepend: true)` when prior rows moved down
167+
168+
This does not recompute grouping. It only compares server row ids already
169+
produced by `GaryxMobileRenderStateMapper`.
170+
171+
## Files
172+
173+
- `Sources/GaryxMobileCore/GaryxTranscriptSyncPlanner.swift`
174+
- `Sources/GaryxMobileCore/GaryxConversationScrollPolicy.swift`
175+
- `App/GaryxMobile/GaryxMobileConversationViews.swift`
176+
- `App/GaryxMobile/GaryxMobileModel+ThreadStream.swift`
177+
- `App/GaryxMobile/GaryxMobileModel+Threads.swift`
178+
- focused tests under `Tests/GaryxMobileCoreTests`
179+
180+
No new app-target source file is planned. If implementation needs one, run
181+
`xcodegen generate` and commit the project change.
182+
183+
## Validation
184+
185+
Required before code review:
186+
187+
- focused red tests turn green
188+
- `swift test --package-path mobile/garyx-mobile`
189+
- `xcodebuild -project mobile/garyx-mobile/GaryxMobile.xcodeproj -target GaryxMobile -sdk iphonesimulator -configuration Debug build CODE_SIGNING_ALLOWED=NO`
190+
191+
The code-review task must reproduce before/after behavior and confirm:
192+
193+
- no automatic load on cold open or light scroll
194+
- stable `hasMoreHistoryBefore`
195+
- idempotent cache/page handling
196+
- prepend keeps reading position stable
197+
- floor/gapless contract remains intact

mobile/garyx-mobile/App/GaryxMobile/GaryxMobileConversationViews.swift

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ struct GaryxConversationView: View {
155155
@State private var scrollStateBox = GaryxConversationScrollStateBox()
156156
@State private var showsScrollToBottomButton = false
157157
@State private var scrollPreservationThreadId: String?
158+
@State private var rowScrollPreservationThreadId: String?
158159
@State private var pendingHistoryPrefetchThreadId: String?
159160
@State private var bottomChromeHeight: CGFloat = 0
160161
@State private var tailScrollRequestGeneration = 0
@@ -236,6 +237,7 @@ struct GaryxConversationView: View {
236237
}
237238
.onChange(of: model.selectedThread?.id) { _, _ in
238239
scrollPreservationThreadId = model.selectedThread?.id
240+
rowScrollPreservationThreadId = model.selectedThread?.id
239241
pendingHistoryPrefetchThreadId = nil
240242
updateScrollState(proxy: proxy) { $0.threadOpened() }
241243
resetTailThinkingPresentation(proxy: proxy)
@@ -259,6 +261,18 @@ struct GaryxConversationView: View {
259261
)
260262
}
261263
}
264+
.onChange(of: model.selectedThreadTurnRows().map(\.id)) { oldValue, newValue in
265+
let threadUnchanged = model.selectedThread?.id == rowScrollPreservationThreadId
266+
rowScrollPreservationThreadId = model.selectedThread?.id
267+
updateScrollState(proxy: proxy) {
268+
$0.renderRowsChanged(
269+
previousIds: oldValue,
270+
currentIds: newValue,
271+
threadUnchanged: threadUnchanged,
272+
hasTailContent: !newValue.isEmpty || showsDebouncedTailThinking
273+
)
274+
}
275+
}
262276
.onChange(of: model.showsTailThinkingIndicator) { _, _ in
263277
syncTailThinkingPresentation(proxy: proxy)
264278
}
@@ -333,14 +347,14 @@ struct GaryxConversationView: View {
333347
}
334348
}
335349
.onAppear {
336-
prefetchOlderHistoryIfNeeded(ignoreDistance: true)
350+
prefetchOlderHistoryIfNeeded()
337351
}
338352
}
339353
GaryxMobileTurnRowsView(
340354
rows: turnRows,
341355
prefetchBoundaryRowCount: garyxHistoryPrefetchBoundaryRows
342356
) {
343-
prefetchOlderHistoryIfNeeded(ignoreDistance: true)
357+
prefetchOlderHistoryIfNeeded()
344358
}
345359
if showsDebouncedTailThinking {
346360
GaryxThinkingLabel()
@@ -617,13 +631,12 @@ struct GaryxConversationView: View {
617631
].joined(separator: "|")
618632
}
619633

620-
private func prefetchOlderHistoryIfNeeded(ignoreDistance: Bool = false) {
634+
private func prefetchOlderHistoryIfNeeded() {
621635
guard let threadId = model.selectedThread?.id,
622636
scrollStateBox.state.shouldPrefetchOlderHistory(
623637
hasMoreHistoryBefore: model.selectedThreadHasMoreHistoryBefore,
624638
isLoadingOlderHistory: model.isLoadingOlderThreadHistory,
625-
hasPendingPrefetch: pendingHistoryPrefetchThreadId == threadId,
626-
ignoreDistance: ignoreDistance
639+
hasPendingPrefetch: pendingHistoryPrefetchThreadId == threadId
627640
) else {
628641
return
629642
}

mobile/garyx-mobile/App/GaryxMobile/GaryxMobileModel+ThreadStream.swift

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -236,15 +236,24 @@ extension GaryxMobileModel {
236236
private func applyThreadRenderSnapshot(_ snapshot: GaryxRenderSnapshot, threadId: String) {
237237
guard selectedThread?.id == threadId else { return }
238238
setRenderSnapshot(snapshot, for: threadId)
239-
applyRenderWindowPagination(snapshot.window, threadId: threadId)
239+
let pagination = applyRenderWindowPagination(snapshot.window, threadId: threadId)
240240
let base = transcriptSnapshot(for: threadId)
241+
let windowHasMoreBefore: Bool
242+
let windowNextBeforeIndex: Int?
243+
if let pagination {
244+
windowHasMoreBefore = pagination.hasMoreBefore
245+
windowNextBeforeIndex = pagination.nextBeforeIndex
246+
} else {
247+
windowHasMoreBefore = base?.hasMoreBefore ?? false
248+
windowNextBeforeIndex = base?.nextBeforeIndex
249+
}
241250
let window = GaryxCachedTranscript(
242251
threadId: threadId,
243252
savedAt: Date(),
244253
messages: base?.messages ?? [],
245254
renderSnapshot: snapshot,
246-
hasMoreBefore: base?.hasMoreBefore ?? false,
247-
nextBeforeIndex: base?.nextBeforeIndex
255+
hasMoreBefore: windowHasMoreBefore,
256+
nextBeforeIndex: windowNextBeforeIndex
248257
)
249258
cachedTranscriptSnapshots[threadId] = window
250259
if !isThreadBusy(threadId) {
@@ -254,20 +263,24 @@ extension GaryxMobileModel {
254263
scheduleSelectedThreadStreamFlush(for: threadId)
255264
}
256265

257-
private func applyRenderWindowPagination(_ renderWindow: GaryxRenderWindow?, threadId: String) {
258-
guard selectedThread?.id == threadId else { return }
266+
@discardableResult
267+
private func applyRenderWindowPagination(
268+
_ renderWindow: GaryxRenderWindow?,
269+
threadId: String
270+
) -> GaryxHistoryPaginationState? {
271+
guard selectedThread?.id == threadId else { return nil }
259272
guard let renderWindow else {
260273
selectedThreadRenderFloorByThread[threadId] = nil
261-
return
274+
return nil
262275
}
263276
selectedThreadRenderFloorByThread[threadId] = renderWindow.floorSeq
264-
if renderWindow.hasMoreAbove, renderWindow.floorSeq > 1 {
265-
selectedThreadHasMoreHistoryBefore = true
266-
selectedThreadNextHistoryBeforeIndex = renderWindow.floorSeq - 1
267-
} else {
268-
selectedThreadHasMoreHistoryBefore = false
269-
selectedThreadNextHistoryBeforeIndex = nil
270-
}
277+
let next = GaryxHistoryPaginationPlanner.applyingRenderWindow(
278+
renderWindow,
279+
current: selectedHistoryPaginationState(),
280+
cached: cachedHistoryPaginationState(for: threadId)
281+
)
282+
applySelectedThreadHistoryPagination(next)
283+
return next
271284
}
272285

273286
/// Leading-throttle (mirrors scheduleAssistantDeltaFlush): the first row schedules

mobile/garyx-mobile/App/GaryxMobile/GaryxMobileModel+Threads.swift

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1516,21 +1516,44 @@ extension GaryxMobileModel {
15161516
preservingLoadedOlderPages: Bool = false
15171517
) {
15181518
guard selectedThread?.id == threadId else { return }
1519-
if preservingLoadedOlderPages,
1520-
let oldestLoadedIndex = oldestLoadedHistoryIndex(for: threadId),
1521-
let latestPageStartIndex = preserveRemoteBeforeIndex(from: transcript),
1522-
oldestLoadedIndex < latestPageStartIndex {
1523-
if oldestLoadedIndex > 0 {
1524-
selectedThreadHasMoreHistoryBefore = true
1525-
selectedThreadNextHistoryBeforeIndex = oldestLoadedIndex
1526-
} else {
1527-
selectedThreadHasMoreHistoryBefore = false
1528-
selectedThreadNextHistoryBeforeIndex = nil
1529-
}
1530-
return
1519+
let page = GaryxHistoryPaginationPage(
1520+
hasMoreBefore: transcript.pageInfo?.hasMoreBefore ?? false,
1521+
nextBeforeIndex: transcript.pageInfo?.nextBeforeIndex,
1522+
oldestLoadedIndex: oldestLoadedHistoryIndex(for: threadId),
1523+
latestPageStartIndex: preserveRemoteBeforeIndex(from: transcript)
1524+
)
1525+
let next = GaryxHistoryPaginationPlanner.applyingTranscriptPage(
1526+
page,
1527+
current: selectedHistoryPaginationState(),
1528+
preservingLoadedOlderPages: preservingLoadedOlderPages
1529+
)
1530+
applySelectedThreadHistoryPagination(next)
1531+
}
1532+
1533+
func selectedHistoryPaginationState() -> GaryxHistoryPaginationState {
1534+
GaryxHistoryPaginationState(
1535+
hasMoreBefore: selectedThreadHasMoreHistoryBefore,
1536+
nextBeforeIndex: selectedThreadNextHistoryBeforeIndex
1537+
)
1538+
}
1539+
1540+
func cachedHistoryPaginationState(for threadId: String) -> GaryxHistoryPaginationState? {
1541+
guard let snapshot = transcriptSnapshot(for: threadId) else {
1542+
return nil
1543+
}
1544+
return GaryxHistoryPaginationState(
1545+
hasMoreBefore: snapshot.hasMoreBefore,
1546+
nextBeforeIndex: snapshot.nextBeforeIndex
1547+
)
1548+
}
1549+
1550+
func applySelectedThreadHistoryPagination(_ state: GaryxHistoryPaginationState) {
1551+
if selectedThreadHasMoreHistoryBefore != state.hasMoreBefore {
1552+
selectedThreadHasMoreHistoryBefore = state.hasMoreBefore
1553+
}
1554+
if selectedThreadNextHistoryBeforeIndex != state.nextBeforeIndex {
1555+
selectedThreadNextHistoryBeforeIndex = state.nextBeforeIndex
15311556
}
1532-
selectedThreadHasMoreHistoryBefore = transcript.pageInfo?.hasMoreBefore ?? false
1533-
selectedThreadNextHistoryBeforeIndex = transcript.pageInfo?.nextBeforeIndex
15341557
}
15351558

15361559
func oldestLoadedHistoryIndex(for threadId: String) -> Int? {

0 commit comments

Comments
 (0)