|
| 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 |
0 commit comments