fix(conversation): stop scroll-up blank gaps and anchor teleports - #470
Merged
Merged
Conversation
Scrolling up through a paused conversation opened progressively larger blank regions and could teleport the viewport. Three defects compound, all measured against the real 80-message session that produced the report. The delta poll resumed from the max message index already held, but the streamer's after_index window is inclusive, so every tick re-fetched the tail message and stored it as a duplicate-id row. The paused conversation grew from 80 to 156 rows during one capture, each append adding ~342pt of content and firing an anchor pass under the user's scroll. drawDistance was left at the 250px iOS default while single markdown-table rows measure 3,114pt against a 654pt viewport, so any real flick outran the render window and just-passed rows unmounted into visible blanks. getItemType lumped collapsed 46pt thinking headers together with tool and diff cards, poisoning the per-type average height FlashList uses to place unmeasured rows. Content height thrashed by up to 19,898pt in a single relayout, which maintainVisibleContentPosition absorbed as the observed viewport jumps. After the fixes the same scripted protocol shows zero data churn while paused, relayouts capped at 1,742pt, and every offset correction matching its paired content shift within 40pt. Three existing tests mocked after_index as exclusive, which encoded the bug rather than the server contract; their first-hop cursors are corrected alongside the fix.
RonenMars
added a commit
that referenced
this pull request
Aug 1, 2026
## Problem
Scrolling up through a paused conversation opened progressively larger
blank regions where messages should be, and a small upward scroll could
teleport the viewport far back in the conversation.
Space was reserved (the scroll indicator stayed proportional) but rows
were not drawn, gaps healed on a downward scroll, and they repeatedly
opened directly below a collapsed `Reasoning` row.
## Root causes
Three independent defects compound here.
All three were reproduced and measured against the real session that
produced the report (80 messages, paused, live streamer) with temporary
instrumentation before any code changed; the instrumentation is not part
of this diff.
### 1. The delta poll grew a paused conversation, one duplicate row per
tick
The streamer's `after_index` window is inclusive — `[after_index,
after_index + limit)` — but the tail view resumed from `deriveCursor`,
which is the max message index the client already holds.
So every poll re-fetched the last message and stored it as a new page,
and `mergeConversationPages` emitted it again with the same
`${convId}-${index}` id.
Measured on the untouched paused conversation: the rendered list grew
from 80 to 156 rows during the capture, roughly one phantom row per 6
seconds, every copy carrying the duplicate id `<convId>-80`.
Duplicate ids break both FlashList's `keyExtractor` and the
`maintainVisibleContentPosition` anchor, which stores the first visible
item's key; each append also added ~342 pt of content height and
triggered an autoscroll plus an anchor pass underneath the user's
finger.
### 2. `drawDistance` was left at the 250 px default against rows
several viewports tall
`drawDistance` is pixels of pre-rendered runway, not rows, and FlashList
v2 splits the `2 × drawDistance` buffer 70/30 toward the scroll
direction — scrolling up pre-renders only ~350 px above the viewport and
evicts rows ~150 px below it.
Measured row heights in this conversation: 3,114 pt, 2,146 pt, and 1,892
pt against a 654 pt viewport, so a single markdown-table answer is
roughly five viewports tall.
Any real flick outruns that buffer and the just-passed row unmounts into
a visible blank, which is why the gaps opened directly below collapsed
`Reasoning` rows: those are the cheapest rows to draw and the last ones
that still made it.
### 3. One coarse `getItemType` pool poisoned the estimated-height
average
FlashList v2 positions not-yet-measured rows from a per-item-type
running average (`heightAverageWindow`; see Shopify/flash-list#2053 for
the mechanism).
A 46 pt collapsed thinking row shared the `tool` pool with tool and diff
cards, and 300-character replies shared `assistant` with ~3,000 pt table
answers, so estimates for unvisited regions were far off and every real
measurement re-laid-out everything around it.
Measured content-height thrash between consecutive scroll events: 18k →
45k → 42k → 57k → 66k pt, with a maximum single relayout of ±19,898 pt.
`maintainVisibleContentPosition` has to absorb that as offset
corrections, and the captured pathological case is exactly the reported
teleport: a +700 pt offset jump paired with relayouts of +12,174 pt and
−3,490 pt.
This is the maintainer-documented failure regime for items taller than
`2 × drawDistance` (Shopify/flash-list#2136 and its fix PR #2138; the
anchor-loss half shipped in 2.3.1, the slow-convergence half is still
open upstream as #2052 and #2018).
## Fixes
- `hooks/useConversations.ts` — the `{ resume }` delta now requests
`after_index = maxHeld + 1`. The `{ after }` variant already carries the
server's own `next_after_index`, which is the first index the client
does not have, so it is unchanged.
- `hooks/useConversations.ts` — `mergeConversationPages` dedups by
`message_index` at the single flatten choke point, so overlapping server
windows can never produce duplicate row ids again regardless of
transport behavior. This also covers anchored windows, which the server
deliberately widens backward near the tail.
- `components/conversation/ConversationHistoryList.tsx` —
`drawDistance={2000}`, which keeps the engaged window ahead of momentum
scrolling and clears the `item > 2 × drawDistance` regime for rows up to
4,000 px (tallest measured: 3,114 px).
- `components/conversation/ConversationHistoryList.tsx` — `getItemType`
split into `thinking` / `diff` / `tool` / `user` / `assistant`, so each
recycling pool's height average tracks rows that actually look alike.
- `components/conversation/ConversationHistoryList.tsx` —
`ListHeaderComponent` and `ListFooterComponent` element identity is
memoized. Inline conditionals recreated them on every parent render, and
upstream guidance (Shopify/flash-list#1844) flags that as an
anchor-churn source during `onStartReached` fetches.
- `components/conversation/ConversationHistoryList.tsx` — the animate-id
bookkeeping walk is keyed on the messages array identity instead of
running in every render body.
## Verification
Same scripted scroll protocol, before and after, on the real session
against the live streamer.
| Metric | Before | After |
|---|---|---|
| Data churn while paused | 78 appends, 80 → 156 rows, duplicate ids
every tick | 0 appends, no duplicate ids |
| Content-height relayouts > 400 pt | 19, max ±19,898 pt | 4, max 1,742
pt |
| Offset jumps > 600 pt | 3, diverging from the paired content shift
(+700 pt against +12,174 / −3,490 pt) | 4, each matching its paired
relayout within ±40 pt |
| Fast-fling gauntlet to the top | blank viewport frames | every frame
dense, lands on the first message |
| Rows whose height changed after first layout | 0 | 0 |
## Ruled out
- **Re-render storm from the FAB `setState` at
`scrollEventThrottle={16}`** — component re-renders during scroll
measured at approximately 0 per second, since React bails out on
same-value sets. Cell renders only spike during mount fill. The scroll
handler is unchanged.
- **Entrance animations in recycled cells** — zero rows rendered with
`animateIn=true` across every paused-session capture.
- **Collapsed `Reasoning` rows reporting expanded height** —
`ThinkingCard`, `ToolCard`, and `DiffViewer` all render collapsed
content only via `useRecyclingState(false)`, and their measured heights
are honest. The gaps neighbor these rows because they are the cheapest
rows to draw, not because they misreport.
- **Post-mount height settling from markdown or prism** — zero rows
changed height after first layout in either run. Tables render as
wrapped text and prism tokenizes synchronously.
- **`onStartReached` re-fire or pagination churn** — zero `startReached`
fires during the capture, since the conversation fits in one 80-message
page. The header-identity fix hardens this path regardless.
- **Offset projection over-projecting under slow renders** —
`averageRenderTime` is clamped to the [16, 32] ms range in
`RenderTimeTracker`, which bounds projection to roughly 100–160 pt per
event, far too small to produce the observed displacement.
## Tests
`__tests__/unit/hooks/useConversations.test.tsx` — the existing
resume-delta test pinned the off-by-one by asserting `after_index=2` for
a held max index of 2; it now asserts `after_index=3` and rejects
`after_index=2`.
A new test feeds an overlapping delta page and requires the merge to
produce unique ids.
On the pre-fix code these two fail and the other 53 in the file pass.
`e2e/07_conversation_scroll_gaps.yaml` with
`e2e/fixtures/conv-scroll-gaps.json` — the fixture's height distribution
mirrors the measured session: two roughly 3,000 pt markdown-table
answers and a tall code fence among 50–350 pt rows, with collapsed
thinking rows at the gap edges.
The flow dwells at the tail so the delta poll runs, asserts the tail has
not grown, then flings to the top with no settle time and requires the
top rows drawn within a tight timeout.
Verified failing on a pre-fix Release build (at the tail-growth
assertion) and passing on the fixed Release build.
Wired into `test:e2e:mock`.
The flow pairs inline rather than via `setup.yaml` because Maestro
2.6.1's `hideKeyboard` does not dismiss the iOS 26 keyboard; the prelude
scrolls the form instead and submits with the token field's return key.
## Notes
`__tests__/e2e/onboarding-flow.test.tsx` has one failing test on this
branch, and it fails identically on the base branch with these changes
stashed, so it is pre-existing and unrelated.
`LiveConversationView` renders its own inline FlashList with the same
default `drawDistance` and no `getItemType`. It benefits from the
data-layer fixes here, but its list props deserve the same treatment in
a follow-up.
The `↑ Top` FAB overlaps list content while scrolling, which is visible
in the original report's screenshots. Separate cosmetic issue, untouched
here.
RonenMars
added a commit
that referenced
this pull request
Aug 1, 2026
…ocker The `↑ Top` pill in the conversation detail view is horizontally centered over the message column, and message bubbles span nearly the full width, so it lands on the text rather than in a margin. It was a solid accent-blue pill with opaque white bold text, conditionally mounted with no fade, which made it read as a modal blocker: `| Cypress | Testing | s[↑ Top]ntend | strong |`. Its two sibling implementations already solve this correctly. `ConversationList` and `TerminalOutput` both use a translucent chip with muted text inside an `Animated.View` that fades opacity over 220ms and wraps it in `pointerEvents="box-none"`. This brings `ConversationHistoryList` in line with them rather than inventing a third visual language. The show/hide condition moves from a `useState` toggle to a shared value, so the pill fades instead of popping and is always mounted. `pointerEvents` tracks opacity in the same animated style, so a faded-out pill cannot intercept list touches. Fixed purely as an overlay. No `contentContainerStyle` padding, `drawDistance`, `maintainVisibleContentPosition`, or `getItemType` changes — those are load-bearing for the FlashList v2 anchoring stabilized in #470, and padding that toggles with FAB visibility would mutate content height mid-scroll. Also localizes the five hardcoded English accessibility labels on these FABs. `ConversationHistoryList` had `"Scroll to top"`, `ConversationList` had `"Scroll to top"` and `"Scroll to bottom"`, and `TerminalOutput` had `"Jump to top"` and `"Jump to bottom"`, all while the bottom FAB one line below already used a translation key. New `nav.scrollToTop` and `nav.scrollToBottom` keys land in `common.json` for en, he, ru, and ar, since all three components already load that namespace. The terminal labels change wording from "Jump" to "Scroll" so the label is consistent app-wide; `TerminalOutput.test.tsx` is updated to match. `e2e/07_conversation_scroll_gaps.yaml` gains a closing block that scrolls back down past the 200pt threshold, asserts the FAB by `testID`, taps it, and requires the first message to be visible. All existing assertions are untouched, including the `SPEC-MARKER` duplicate-append guard from #470.
RonenMars
added a commit
that referenced
this pull request
Aug 1, 2026
…ocker (#476) ## Problem The `↑ Top` FAB in the conversation detail view appears once you scroll past ~200px, horizontally centered at the top of the list, and sits on top of message content. It was fully opaque, so text behind it was unreadable, and it intercepted touches in its rect. Message bubbles span nearly the full width and the pill is centered, so it lands squarely on the text column rather than in a margin. Occlusions captured during the #470 investigation: `…write the spec doc for th[↑ Top]n plan.` and `| Cypress | Testing | s[↑ Top]ntend | strong |`. ## Root cause `ConversationHistoryList` was the odd one out among three sibling implementations of the same affordance. | | background | show/hide | wrapper | |---|---|---|---| | `ConversationHistoryList` (before) | `theme.text.accent`, opaque | conditional mount/unmount, no fade | none | | `ConversationList` | `rgba(31, 111, 235, 0.14)` + `rgba(88, 166, 255, 0.2)` border | `Animated.View` + `useAnimatedStyle` opacity, 220ms `withTiming` | `pointerEvents="box-none"` | | `TerminalOutput` | `rgba(31, 111, 235, 0.18)` | same animated pattern | `pointerEvents="box-none"` | The two working versions use a translucent chip with muted text that reads as an overlay you can see through, and they fade rather than pop. `ConversationHistoryList` used a solid accent-blue pill with white bold text, which reads as a modal blocker. ## Fix `ConversationHistoryList` now matches its two siblings rather than introducing a third visual language: translucent background, muted 12px text, `Animated.View` + `pointerEvents="box-none"` wrapper, 220ms fade in/out. The show/hide condition moves from a `useState` toggle to a shared value, so the pill is always mounted and fades instead of popping. `pointerEvents` tracks opacity inside the same animated style, so a faded-out pill cannot intercept list touches. This is fixed purely as an overlay. There are no `contentContainerStyle` padding, `drawDistance`, `maintainVisibleContentPosition`, or `getItemType` changes — those lines are load-bearing for the FlashList v2 anchoring stabilized in #470, which measured content-height swings up to ±19,898pt causing viewport teleports. Padding that toggles with FAB visibility would mutate content height mid-scroll and reintroduce that class of bug. The bottom `CaretDown` FAB is untouched; it sits bottom-right in the gutter and was not reported. ## Before / after Same fixture conversation, same scroll offset, driven by an identical scripted gesture sequence on a booted simulator against two Release builds. In the before frame the table row reads `| Express | Language | s[↑ Top] backend |`. In the after frame the same row reads `| Express | Language | senior | backend |` straight through the pill. | Before | After | |---|---| | <img src="https://raw.githubusercontent.com/RonenMars/threadbase-mobile/ad16c92e491e26b7e350bc35b6bc3c46acebd887/docs/screenshots/top-fab-overlap-before.png" width="320"> | <img src="https://raw.githubusercontent.com/RonenMars/threadbase-mobile/ad16c92e491e26b7e350bc35b6bc3c46acebd887/docs/screenshots/top-fab-overlap-after.png" width="320"> | ## Accessibility labels The top FAB's `accessibilityLabel` was a hardcoded English `"Scroll to top"` while the bottom FAB directly below it already used `t('action.scrollToBottom')`. The same gap existed in `ConversationList` (`"Scroll to top"`, `"Scroll to bottom"`) and `TerminalOutput` (`"Jump to top"`, `"Jump to bottom"`), five hardcoded strings in total. New `nav.scrollToTop` and `nav.scrollToBottom` keys land in `common.json` for en, he, ru, and ar, since all three components already load that namespace. The existing `conversation.json` `action.scrollToBottom` key stays in use by the bottom FAB, so no key is orphaned. The terminal labels change wording from "Jump" to "Scroll" so the announced label is consistent app-wide, and `TerminalOutput.test.tsx` is updated to match. ## Verification `e2e/07_conversation_scroll_gaps.yaml` gains a closing block that scrolls back down past the 200pt threshold, waits for the FAB by `testID`, taps it, and asserts `SCROLL-GAPS-TOP-MARKER` (the first message) is visible. That proves the control is still hittable and still scrolls to the top after the restyle, which is a real risk now that the pill is always mounted: an opacity-0 overlay stuck on `pointerEvents: auto` would swallow list touches, and one that never regains `auto` would stop working. All of the flow's existing assertions are intact, including the `SPEC-MARKER` duplicate-append guard from #470. The flow already runs in `test:e2e:mock`, so no script wiring changed. The full flow passes end to end on a Release build. - `npm run lint` — 0 errors, 5 warnings, all pre-existing and none in the touched files. - Conversation and terminal Jest suites — 26 suites, 277 tests, all passing. - `npm run test:i18n` — 3 suites, 55 passed, 1 skipped. Two pre-existing conditions on this base, neither introduced here. `npx tsc --noEmit` reports 14 errors, and the count is identical on the base branch with these changes stashed. `npm run lint:i18n` fails its `--max-warnings=0` gate on a `react-hooks/exhaustive-deps` warning in `app/session/[id].tsx`, and it fails identically on the base branch. ## Follow-ups, not in this PR All three copies of this FAB are now near-identical and could be extracted into one shared component. Three near-identical copies is a pre-existing condition, so that is deliberately left alone here. `LiveConversationView` renders its own inline FlashList and was noted in #470 as deserving the same list-prop treatment; it has no top FAB and is unaffected by this change.
RonenMars
added a commit
that referenced
this pull request
Aug 1, 2026
## Problem
Scrolling up through a paused conversation opened progressively larger
blank regions where messages should be, and a small upward scroll could
teleport the viewport far back in the conversation.
Space was reserved (the scroll indicator stayed proportional) but rows
were not drawn, gaps healed on a downward scroll, and they repeatedly
opened directly below a collapsed `Reasoning` row.
## Root causes
Three independent defects compound here.
All three were reproduced and measured against the real session that
produced the report (80 messages, paused, live streamer) with temporary
instrumentation before any code changed; the instrumentation is not part
of this diff.
### 1. The delta poll grew a paused conversation, one duplicate row per
tick
The streamer's `after_index` window is inclusive — `[after_index,
after_index + limit)` — but the tail view resumed from `deriveCursor`,
which is the max message index the client already holds.
So every poll re-fetched the last message and stored it as a new page,
and `mergeConversationPages` emitted it again with the same
`${convId}-${index}` id.
Measured on the untouched paused conversation: the rendered list grew
from 80 to 156 rows during the capture, roughly one phantom row per 6
seconds, every copy carrying the duplicate id `<convId>-80`.
Duplicate ids break both FlashList's `keyExtractor` and the
`maintainVisibleContentPosition` anchor, which stores the first visible
item's key; each append also added ~342 pt of content height and
triggered an autoscroll plus an anchor pass underneath the user's
finger.
### 2. `drawDistance` was left at the 250 px default against rows
several viewports tall
`drawDistance` is pixels of pre-rendered runway, not rows, and FlashList
v2 splits the `2 × drawDistance` buffer 70/30 toward the scroll
direction — scrolling up pre-renders only ~350 px above the viewport and
evicts rows ~150 px below it.
Measured row heights in this conversation: 3,114 pt, 2,146 pt, and 1,892
pt against a 654 pt viewport, so a single markdown-table answer is
roughly five viewports tall.
Any real flick outruns that buffer and the just-passed row unmounts into
a visible blank, which is why the gaps opened directly below collapsed
`Reasoning` rows: those are the cheapest rows to draw and the last ones
that still made it.
### 3. One coarse `getItemType` pool poisoned the estimated-height
average
FlashList v2 positions not-yet-measured rows from a per-item-type
running average (`heightAverageWindow`; see Shopify/flash-list#2053 for
the mechanism).
A 46 pt collapsed thinking row shared the `tool` pool with tool and diff
cards, and 300-character replies shared `assistant` with ~3,000 pt table
answers, so estimates for unvisited regions were far off and every real
measurement re-laid-out everything around it.
Measured content-height thrash between consecutive scroll events: 18k →
45k → 42k → 57k → 66k pt, with a maximum single relayout of ±19,898 pt.
`maintainVisibleContentPosition` has to absorb that as offset
corrections, and the captured pathological case is exactly the reported
teleport: a +700 pt offset jump paired with relayouts of +12,174 pt and
−3,490 pt.
This is the maintainer-documented failure regime for items taller than
`2 × drawDistance` (Shopify/flash-list#2136 and its fix PR #2138; the
anchor-loss half shipped in 2.3.1, the slow-convergence half is still
open upstream as #2052 and #2018).
## Fixes
- `hooks/useConversations.ts` — the `{ resume }` delta now requests
`after_index = maxHeld + 1`. The `{ after }` variant already carries the
server's own `next_after_index`, which is the first index the client
does not have, so it is unchanged.
- `hooks/useConversations.ts` — `mergeConversationPages` dedups by
`message_index` at the single flatten choke point, so overlapping server
windows can never produce duplicate row ids again regardless of
transport behavior. This also covers anchored windows, which the server
deliberately widens backward near the tail.
- `components/conversation/ConversationHistoryList.tsx` —
`drawDistance={2000}`, which keeps the engaged window ahead of momentum
scrolling and clears the `item > 2 × drawDistance` regime for rows up to
4,000 px (tallest measured: 3,114 px).
- `components/conversation/ConversationHistoryList.tsx` — `getItemType`
split into `thinking` / `diff` / `tool` / `user` / `assistant`, so each
recycling pool's height average tracks rows that actually look alike.
- `components/conversation/ConversationHistoryList.tsx` —
`ListHeaderComponent` and `ListFooterComponent` element identity is
memoized. Inline conditionals recreated them on every parent render, and
upstream guidance (Shopify/flash-list#1844) flags that as an
anchor-churn source during `onStartReached` fetches.
- `components/conversation/ConversationHistoryList.tsx` — the animate-id
bookkeeping walk is keyed on the messages array identity instead of
running in every render body.
## Verification
Same scripted scroll protocol, before and after, on the real session
against the live streamer.
| Metric | Before | After |
|---|---|---|
| Data churn while paused | 78 appends, 80 → 156 rows, duplicate ids
every tick | 0 appends, no duplicate ids |
| Content-height relayouts > 400 pt | 19, max ±19,898 pt | 4, max 1,742
pt |
| Offset jumps > 600 pt | 3, diverging from the paired content shift
(+700 pt against +12,174 / −3,490 pt) | 4, each matching its paired
relayout within ±40 pt |
| Fast-fling gauntlet to the top | blank viewport frames | every frame
dense, lands on the first message |
| Rows whose height changed after first layout | 0 | 0 |
## Ruled out
- **Re-render storm from the FAB `setState` at
`scrollEventThrottle={16}`** — component re-renders during scroll
measured at approximately 0 per second, since React bails out on
same-value sets. Cell renders only spike during mount fill. The scroll
handler is unchanged.
- **Entrance animations in recycled cells** — zero rows rendered with
`animateIn=true` across every paused-session capture.
- **Collapsed `Reasoning` rows reporting expanded height** —
`ThinkingCard`, `ToolCard`, and `DiffViewer` all render collapsed
content only via `useRecyclingState(false)`, and their measured heights
are honest. The gaps neighbor these rows because they are the cheapest
rows to draw, not because they misreport.
- **Post-mount height settling from markdown or prism** — zero rows
changed height after first layout in either run. Tables render as
wrapped text and prism tokenizes synchronously.
- **`onStartReached` re-fire or pagination churn** — zero `startReached`
fires during the capture, since the conversation fits in one 80-message
page. The header-identity fix hardens this path regardless.
- **Offset projection over-projecting under slow renders** —
`averageRenderTime` is clamped to the [16, 32] ms range in
`RenderTimeTracker`, which bounds projection to roughly 100–160 pt per
event, far too small to produce the observed displacement.
## Tests
`__tests__/unit/hooks/useConversations.test.tsx` — the existing
resume-delta test pinned the off-by-one by asserting `after_index=2` for
a held max index of 2; it now asserts `after_index=3` and rejects
`after_index=2`.
A new test feeds an overlapping delta page and requires the merge to
produce unique ids.
On the pre-fix code these two fail and the other 53 in the file pass.
`e2e/07_conversation_scroll_gaps.yaml` with
`e2e/fixtures/conv-scroll-gaps.json` — the fixture's height distribution
mirrors the measured session: two roughly 3,000 pt markdown-table
answers and a tall code fence among 50–350 pt rows, with collapsed
thinking rows at the gap edges.
The flow dwells at the tail so the delta poll runs, asserts the tail has
not grown, then flings to the top with no settle time and requires the
top rows drawn within a tight timeout.
Verified failing on a pre-fix Release build (at the tail-growth
assertion) and passing on the fixed Release build.
Wired into `test:e2e:mock`.
The flow pairs inline rather than via `setup.yaml` because Maestro
2.6.1's `hideKeyboard` does not dismiss the iOS 26 keyboard; the prelude
scrolls the form instead and submits with the token field's return key.
## Notes
`__tests__/e2e/onboarding-flow.test.tsx` has one failing test on this
branch, and it fails identically on the base branch with these changes
stashed, so it is pre-existing and unrelated.
`LiveConversationView` renders its own inline FlashList with the same
default `drawDistance` and no `getItemType`. It benefits from the
data-layer fixes here, but its list props deserve the same treatment in
a follow-up.
The `↑ Top` FAB overlaps list content while scrolling, which is visible
in the original report's screenshots. Separate cosmetic issue, untouched
here.
RonenMars
added a commit
that referenced
this pull request
Aug 1, 2026
…ocker (#476) ## Problem The `↑ Top` FAB in the conversation detail view appears once you scroll past ~200px, horizontally centered at the top of the list, and sits on top of message content. It was fully opaque, so text behind it was unreadable, and it intercepted touches in its rect. Message bubbles span nearly the full width and the pill is centered, so it lands squarely on the text column rather than in a margin. Occlusions captured during the #470 investigation: `…write the spec doc for th[↑ Top]n plan.` and `| Cypress | Testing | s[↑ Top]ntend | strong |`. ## Root cause `ConversationHistoryList` was the odd one out among three sibling implementations of the same affordance. | | background | show/hide | wrapper | |---|---|---|---| | `ConversationHistoryList` (before) | `theme.text.accent`, opaque | conditional mount/unmount, no fade | none | | `ConversationList` | `rgba(31, 111, 235, 0.14)` + `rgba(88, 166, 255, 0.2)` border | `Animated.View` + `useAnimatedStyle` opacity, 220ms `withTiming` | `pointerEvents="box-none"` | | `TerminalOutput` | `rgba(31, 111, 235, 0.18)` | same animated pattern | `pointerEvents="box-none"` | The two working versions use a translucent chip with muted text that reads as an overlay you can see through, and they fade rather than pop. `ConversationHistoryList` used a solid accent-blue pill with white bold text, which reads as a modal blocker. ## Fix `ConversationHistoryList` now matches its two siblings rather than introducing a third visual language: translucent background, muted 12px text, `Animated.View` + `pointerEvents="box-none"` wrapper, 220ms fade in/out. The show/hide condition moves from a `useState` toggle to a shared value, so the pill is always mounted and fades instead of popping. `pointerEvents` tracks opacity inside the same animated style, so a faded-out pill cannot intercept list touches. This is fixed purely as an overlay. There are no `contentContainerStyle` padding, `drawDistance`, `maintainVisibleContentPosition`, or `getItemType` changes — those lines are load-bearing for the FlashList v2 anchoring stabilized in #470, which measured content-height swings up to ±19,898pt causing viewport teleports. Padding that toggles with FAB visibility would mutate content height mid-scroll and reintroduce that class of bug. The bottom `CaretDown` FAB is untouched; it sits bottom-right in the gutter and was not reported. ## Before / after Same fixture conversation, same scroll offset, driven by an identical scripted gesture sequence on a booted simulator against two Release builds. In the before frame the table row reads `| Express | Language | s[↑ Top] backend |`. In the after frame the same row reads `| Express | Language | senior | backend |` straight through the pill. | Before | After | |---|---| | <img src="https://raw.githubusercontent.com/RonenMars/threadbase-mobile/ad16c92e491e26b7e350bc35b6bc3c46acebd887/docs/screenshots/top-fab-overlap-before.png" width="320"> | <img src="https://raw.githubusercontent.com/RonenMars/threadbase-mobile/ad16c92e491e26b7e350bc35b6bc3c46acebd887/docs/screenshots/top-fab-overlap-after.png" width="320"> | ## Accessibility labels The top FAB's `accessibilityLabel` was a hardcoded English `"Scroll to top"` while the bottom FAB directly below it already used `t('action.scrollToBottom')`. The same gap existed in `ConversationList` (`"Scroll to top"`, `"Scroll to bottom"`) and `TerminalOutput` (`"Jump to top"`, `"Jump to bottom"`), five hardcoded strings in total. New `nav.scrollToTop` and `nav.scrollToBottom` keys land in `common.json` for en, he, ru, and ar, since all three components already load that namespace. The existing `conversation.json` `action.scrollToBottom` key stays in use by the bottom FAB, so no key is orphaned. The terminal labels change wording from "Jump" to "Scroll" so the announced label is consistent app-wide, and `TerminalOutput.test.tsx` is updated to match. ## Verification `e2e/07_conversation_scroll_gaps.yaml` gains a closing block that scrolls back down past the 200pt threshold, waits for the FAB by `testID`, taps it, and asserts `SCROLL-GAPS-TOP-MARKER` (the first message) is visible. That proves the control is still hittable and still scrolls to the top after the restyle, which is a real risk now that the pill is always mounted: an opacity-0 overlay stuck on `pointerEvents: auto` would swallow list touches, and one that never regains `auto` would stop working. All of the flow's existing assertions are intact, including the `SPEC-MARKER` duplicate-append guard from #470. The flow already runs in `test:e2e:mock`, so no script wiring changed. The full flow passes end to end on a Release build. - `npm run lint` — 0 errors, 5 warnings, all pre-existing and none in the touched files. - Conversation and terminal Jest suites — 26 suites, 277 tests, all passing. - `npm run test:i18n` — 3 suites, 55 passed, 1 skipped. Two pre-existing conditions on this base, neither introduced here. `npx tsc --noEmit` reports 14 errors, and the count is identical on the base branch with these changes stashed. `npm run lint:i18n` fails its `--max-warnings=0` gate on a `react-hooks/exhaustive-deps` warning in `app/session/[id].tsx`, and it fails identically on the base branch. ## Follow-ups, not in this PR All three copies of this FAB are now near-identical and could be extracted into one shared component. Three near-identical copies is a pre-existing condition, so that is deliberately left alone here. `LiveConversationView` renders its own inline FlashList and was noted in #470 as deserving the same list-prop treatment; it has no top FAB and is unaffected by this change.
RonenMars
added a commit
that referenced
this pull request
Aug 1, 2026
…500) The landing executed on 2026-08-01 as #497, #498 and #499. All three slices merged with zero conflicts; the final tree comparison against the frozen prep snapshot came back with nine differing files, every one classified EXPECTED — no drift, nothing unexplained. `#457` is closed. This turns the runbook from a plan into a record, and fixes the three claims the run disproved. It targets `land/integration-prep` rather than `main` because that is where the live copy of the document is. ## 1. Guard B's enumeration must not be narrowed to `$FORK..origin/main` This is the substantive one, and it is a latent safety defect rather than a stale number. A companion document rewrote the guard's file enumeration as `log --name-only $FORK..origin/main` and expected it to reproduce the curated 21-file list. It yields 8. Seven of the eight hand-listed commits are **ancestors of `$FORK`**, so that range excludes them by construction. The 15 files it drops include `package.json`, `.github/workflows/test.yml`, `.github/workflows/deploy.yml` and `KICKOFF-landing-runbook.md` — precisely the regressions `fee27061` exists to undo and that slice A excludes by hand. The guard meant to catch that class of mistake would have been blind to all of it. The reason is structural and now stated in the document: **prep's history is older than the fork**, so a slice can revert `main` work that landed *before* the divergence point, not only after it. Any enumeration bounded below by `$FORK` cannot see that class. The curated list stays; the recipe is corrected. Nothing slipped through on the real run, and that was checked rather than asserted: the wider 21-file guard was re-run retroactively across the whole landing, and every removed line is a genuine supersession (`expo-mcp` dropped by #489, `test:e2e:mock` rewritten by #470, the ship scripts moving their `SENTRY_RELEASE` exports, the Sentry investigation losing real org names by design, this document superseding its own text). `.github/workflows/deploy.yml`, `CLAUDE.md` and `docs/troubleshooting.md` are additions only. The `CLAUDE.md` case is written up with its mechanism: both slice B and slice C carried a copy predating #495, Guard B returned additions only on both (+16/−0 and +10/−0), and the reason is that `git rebase` applies patches rather than whole files, so non-overlapping hunks compose. That was likely but never guaranteed — the guard is what turned it from an assumption into a fact. ## 2. `#457` does not close itself The old text said its diff shrinks as slices merge until it is empty and closes itself. After all three slices merged it was still open, still reporting 73 changed files and +3446/−1126, while the tip-to-tip tree diff had drained to the nine EXPECTED files. A PR's diff is computed against the merge-base, and every prep commit landed under a new SHA — slice A as a fresh cherry-pick, B and C as rebased replays — so nothing prep points at became reachable from `main` and the merge-base never moved. Any PR whose content lands by replay rather than by merge must be closed by hand. ## 3. A `--delete-branch` needs its dependents enumerated first New rule, stated generally: before deleting any branch on `origin`, list everything that depends on that ref — open PRs declaring it as their **base branch** included, not only things downstream of it in ancestry. Ancestry-stacked is harmless; this run hit that case when `land/slice-a` was deleted before slice B needed it as a rebase base, and `--onto origin/main $A_END` is the same operation. Base-ref-stacked is not. GitHub closes such a PR automatically and the closure reads like ordinary cleanup. The equivalent landing on the streamer repo hit this an hour earlier: the dependent PR was closed silently, its content dropped out of that landing, and the automation reported success. The check is one `gh pr list --json baseRefName` command. ## Also - The version-conflict path is marked **still unexercised**. `main` was ahead at 188/40 during the real run, but no slice touches `app.json` or `android/app/build.gradle`, so no conflict arose. It was not exercised by the rehearsal either and must not be read as proven. - The `RNSentry` note in the risks table is cleared — #495 closed that gap. - The rehearsal notes are now committed in-repo, so the section points at `docs/landing/` rather than a path outside the repo. ## Deliberately not included `sessionNames.test.ts` still needs adding to the load-sensitive suite list in `docs/troubleshooting.md`. That file lives on `main`, so it wants its own one-line PR there rather than being bundled into a prep-targeted change. It is recorded as outstanding in the document.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Scrolling up through a paused conversation opened progressively larger blank regions where messages should be, and a small upward scroll could teleport the viewport far back in the conversation.
Space was reserved (the scroll indicator stayed proportional) but rows were not drawn, gaps healed on a downward scroll, and they repeatedly opened directly below a collapsed
Reasoningrow.Root causes
Three independent defects compound here.
All three were reproduced and measured against the real session that produced the report (80 messages, paused, live streamer) with temporary instrumentation before any code changed; the instrumentation is not part of this diff.
1. The delta poll grew a paused conversation, one duplicate row per tick
The streamer's
after_indexwindow is inclusive —[after_index, after_index + limit)— but the tail view resumed fromderiveCursor, which is the max message index the client already holds.So every poll re-fetched the last message and stored it as a new page, and
mergeConversationPagesemitted it again with the same${convId}-${index}id.Measured on the untouched paused conversation: the rendered list grew from 80 to 156 rows during the capture, roughly one phantom row per 6 seconds, every copy carrying the duplicate id
<convId>-80.Duplicate ids break both FlashList's
keyExtractorand themaintainVisibleContentPositionanchor, which stores the first visible item's key; each append also added ~342 pt of content height and triggered an autoscroll plus an anchor pass underneath the user's finger.2.
drawDistancewas left at the 250 px default against rows several viewports talldrawDistanceis pixels of pre-rendered runway, not rows, and FlashList v2 splits the2 × drawDistancebuffer 70/30 toward the scroll direction — scrolling up pre-renders only ~350 px above the viewport and evicts rows ~150 px below it.Measured row heights in this conversation: 3,114 pt, 2,146 pt, and 1,892 pt against a 654 pt viewport, so a single markdown-table answer is roughly five viewports tall.
Any real flick outruns that buffer and the just-passed row unmounts into a visible blank, which is why the gaps opened directly below collapsed
Reasoningrows: those are the cheapest rows to draw and the last ones that still made it.3. One coarse
getItemTypepool poisoned the estimated-height averageFlashList v2 positions not-yet-measured rows from a per-item-type running average (
heightAverageWindow; see Shopify/flash-list#2053 for the mechanism).A 46 pt collapsed thinking row shared the
toolpool with tool and diff cards, and 300-character replies sharedassistantwith ~3,000 pt table answers, so estimates for unvisited regions were far off and every real measurement re-laid-out everything around it.Measured content-height thrash between consecutive scroll events: 18k → 45k → 42k → 57k → 66k pt, with a maximum single relayout of ±19,898 pt.
maintainVisibleContentPositionhas to absorb that as offset corrections, and the captured pathological case is exactly the reported teleport: a +700 pt offset jump paired with relayouts of +12,174 pt and −3,490 pt.This is the maintainer-documented failure regime for items taller than
2 × drawDistance(Shopify/flash-list#2136 and its fix PR #2138; the anchor-loss half shipped in 2.3.1, the slow-convergence half is still open upstream as #2052 and #2018).Fixes
hooks/useConversations.ts— the{ resume }delta now requestsafter_index = maxHeld + 1. The{ after }variant already carries the server's ownnext_after_index, which is the first index the client does not have, so it is unchanged.hooks/useConversations.ts—mergeConversationPagesdedups bymessage_indexat the single flatten choke point, so overlapping server windows can never produce duplicate row ids again regardless of transport behavior. This also covers anchored windows, which the server deliberately widens backward near the tail.components/conversation/ConversationHistoryList.tsx—drawDistance={2000}, which keeps the engaged window ahead of momentum scrolling and clears theitem > 2 × drawDistanceregime for rows up to 4,000 px (tallest measured: 3,114 px).components/conversation/ConversationHistoryList.tsx—getItemTypesplit intothinking/diff/tool/user/assistant, so each recycling pool's height average tracks rows that actually look alike.components/conversation/ConversationHistoryList.tsx—ListHeaderComponentandListFooterComponentelement identity is memoized. Inline conditionals recreated them on every parent render, and upstream guidance (FlashList v2 Chat UX Regression: Inconsistent scroll behavior with paginated messages Shopify/flash-list#1844) flags that as an anchor-churn source duringonStartReachedfetches.components/conversation/ConversationHistoryList.tsx— the animate-id bookkeeping walk is keyed on the messages array identity instead of running in every render body.Verification
Same scripted scroll protocol, before and after, on the real session against the live streamer.
Ruled out
setStateatscrollEventThrottle={16}— component re-renders during scroll measured at approximately 0 per second, since React bails out on same-value sets. Cell renders only spike during mount fill. The scroll handler is unchanged.animateIn=trueacross every paused-session capture.Reasoningrows reporting expanded height —ThinkingCard,ToolCard, andDiffViewerall render collapsed content only viauseRecyclingState(false), and their measured heights are honest. The gaps neighbor these rows because they are the cheapest rows to draw, not because they misreport.onStartReachedre-fire or pagination churn — zerostartReachedfires during the capture, since the conversation fits in one 80-message page. The header-identity fix hardens this path regardless.averageRenderTimeis clamped to the [16, 32] ms range inRenderTimeTracker, which bounds projection to roughly 100–160 pt per event, far too small to produce the observed displacement.Tests
__tests__/unit/hooks/useConversations.test.tsx— the existing resume-delta test pinned the off-by-one by assertingafter_index=2for a held max index of 2; it now assertsafter_index=3and rejectsafter_index=2.A new test feeds an overlapping delta page and requires the merge to produce unique ids.
On the pre-fix code these two fail and the other 53 in the file pass.
e2e/07_conversation_scroll_gaps.yamlwithe2e/fixtures/conv-scroll-gaps.json— the fixture's height distribution mirrors the measured session: two roughly 3,000 pt markdown-table answers and a tall code fence among 50–350 pt rows, with collapsed thinking rows at the gap edges.The flow dwells at the tail so the delta poll runs, asserts the tail has not grown, then flings to the top with no settle time and requires the top rows drawn within a tight timeout.
Verified failing on a pre-fix Release build (at the tail-growth assertion) and passing on the fixed Release build.
Wired into
test:e2e:mock.The flow pairs inline rather than via
setup.yamlbecause Maestro 2.6.1'shideKeyboarddoes not dismiss the iOS 26 keyboard; the prelude scrolls the form instead and submits with the token field's return key.Notes
__tests__/e2e/onboarding-flow.test.tsxhas one failing test on this branch, and it fails identically on the base branch with these changes stashed, so it is pre-existing and unrelated.LiveConversationViewrenders its own inline FlashList with the same defaultdrawDistanceand nogetItemType. It benefits from the data-layer fixes here, but its list props deserve the same treatment in a follow-up.The
↑ TopFAB overlaps list content while scrolling, which is visible in the original report's screenshots. Separate cosmetic issue, untouched here.