Skip to content

fix(conversation): stop scroll-up blank gaps and anchor teleports - #470

Merged
RonenMars merged 1 commit into
land/integration-prepfrom
fix/conversation-scroll-gaps
Aug 1, 2026
Merged

fix(conversation): stop scroll-up blank gaps and anchor teleports#470
RonenMars merged 1 commit into
land/integration-prepfrom
fix/conversation-scroll-gaps

Conversation

@RonenMars

Copy link
Copy Markdown
Owner

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.tsmergeConversationPages 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.tsxdrawDistance={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.tsxgetItemType split into thinking / diff / tool / user / assistant, so each recycling pool's height average tracks rows that actually look alike.
  • components/conversation/ConversationHistoryList.tsxListHeaderComponent and ListFooterComponent element 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 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 heightThinkingCard, 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 rendersaverageRenderTime 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.

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
RonenMars merged commit 2452151 into land/integration-prep Aug 1, 2026
11 checks passed
@RonenMars
RonenMars deleted the fix/conversation-scroll-gaps branch August 1, 2026 07:20
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant