Skip to content

desktop: fix scrollback paging and visible history depth#1153

Merged
wesbillman merged 9 commits into
mainfrom
brain/intro-gate-fix
Jun 20, 2026
Merged

desktop: fix scrollback paging and visible history depth#1153
wesbillman merged 9 commits into
mainfrom
brain/intro-gate-fix

Conversation

@wesbillman

Copy link
Copy Markdown
Collaborator

Summary

Fixes the scrollback issues as a single pagination contract instead of a set of one-off tweaks:

  • keeps backward-paged history in cache so the timeline cap does not evict older roots while the user is reading history
  • gates the channel intro on the rendered timeline catching up with the live cache before declaring the true channel start reached
  • fetches historical channel rows with content kinds only, then backfills reactions/edits/deletions by #e reference so page limits buy visible depth without stale overlays
  • shares one older-history pager for cold load and scroll-up: 300-event cold load, 200-event older pages, 30 top-level visible-row target, 3-page per-pass cap
  • drops in-flight older-fetch triggers instead of queueing retry bursts, and requires a parked sentinel to leave/re-enter once the timeline is scrollable
  • pins the older-history spinner in the timeline viewport while loading

Root causes

  1. Visible-row dilution: raw history page size did not map to rendered rows because replies collapse into parents and aux/non-row events do not render as their own rows.
  2. Aux-event dilution/staleness: history limits were spent on reactions/edits/deletions, but fetching only content without a #e backfill would miss late overlays for visible old messages.
  3. Timeline cap vs backward paging: older roots could be evicted from the cache by newest-window normalization, making history look exhausted and flashing the channel intro.
  4. Deferred render gap: the live cache could know about prepended older history one commit before the deferred rendered list caught up.
  5. Parked sentinel retry burst: re-observing a still-intersecting top sentinel queued/replayed extra fetches, producing spinner flashes and message floods.
  6. In-flow spinner: the older-fetch indicator lived at content top, so it was easy to scroll out of view mid-fetch.

Validation

  • bin/pnpm --dir desktop typecheck
  • bin/pnpm --dir desktop test — 1031 passed
  • bin/pnpm --dir desktop check
  • bin/pnpm --dir desktop build
  • bin/pnpm --dir desktop exec playwright test scroll-history --workers=1 — 11 passed

wesbillman and others added 4 commits June 20, 2026 11:27
Fix the scrollback regressions as one coherent pagination contract:

- Keep backward-paged history in the cache instead of applying the newest-window
  cap to older roots, and gate the channel intro on the rendered timeline being
  caught up with the live cache before declaring the true channel start reached.
- Fetch historical channel rows with content kinds only, then backfill reactions,
  edits, and deletions by `#e` reference so page limits buy visible history depth
  without rendering stale overlays.
- Share one older-history pager between cold load and scroll-up. It targets 30
  top-level rendered rows, uses 200-event pages, caps one pass at three pages,
  and starts cold history at 300 events.
- Drop in-flight observer triggers instead of queueing retries, and require a
  parked top sentinel to leave/re-enter before another fetch once the timeline is
  scrollable. This removes the spinner-flash/flood pattern.
- Keep the older-history spinner pinned in the timeline viewport while loading.

Adds regression coverage for the timeline cap, aux backfill, content-kind parity,
non-overlapping history fetches, viewport-visible loading state, and fixtures
large enough to exercise pagination beyond the larger cold window.

Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
The older-history spinner was bound to isFetchingOlder, which clears the
moment the fetch resolves. Rows render off a deferred (concurrent)
snapshot a frame or two later, so the spinner vanished before the new
rows appeared. Hold it visible while the rendered timeline is still
behind the history prepend.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
First channel load showed skeleton, then briefly the older-fetch spinner,
then messages. The cold-load query seeds rows before its row-floor top-up
finishes, so the loading latch settled (dataLength>0) while the fetch was
still running, dropping the skeleton and exposing the spinner. Keep the
timeline in its loading state while the initial fetch is in flight (before
the channel has settled once), so the skeleton stays up until the first
load is fully done. The per-channel settle latch still owns later refetch
blips, so no skeleton bounce on revisit.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Add a scroll-history regression for sparse/reply-heavy cold loads: while the
initial history query is still topping up to the visible-row floor, the timeline
must keep the skeleton visible and must not expose the older-history spinner.

Validation:
- bin/pnpm --dir desktop exec playwright test tests/e2e/scroll-history.spec.ts --workers=1
- bin/pnpm --dir desktop exec biome check tests/e2e/scroll-history.spec.ts

Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
@wesbillman

Copy link
Copy Markdown
Collaborator Author

@codex please review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 22a1a39061

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return {
kinds: [...CHANNEL_AUX_EVENT_KINDS],
"#h": [channelId],
"#e": messageIds,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Backfill tombstones for cached reactions

When a channel is reopened with a cached reaction that was removed while this client was unsubscribed, this aux backfill only queries deletion events whose #e points at loaded message ids. Reaction removals are kind:5 events whose #e points at the reaction event id (desktop/src-tauri/src/events.rs:440-442), so after the main history query became content-only, a stale cached reaction can survive the refetch unless the live-subscription overlap happens to include its deletion. The backfill needs a second pass for deletions targeting fetched/cached aux event ids, or stale aux events need to be dropped when they are no longer returned.

Useful? React with 👍 / 👎.

wesbillman and others added 5 commits June 20, 2026 13:16
Fix the two PR #1153 smoke failures seen in CI:

- Trim the scroll-history cap-crossing fixture from 2600 to 2100 older roots,
  still above the 2000-row cap but below the 30s CI timeout cliff.
- Update the small-channel content test for the new correct behavior: a tiny
  fully-loaded channel is genuinely at its start, so the channel intro is
  visible immediately while welcome-only actions remain hidden.

Validation:
- CI=1 bin/pnpm --dir desktop exec playwright test tests/e2e/channels.spec.ts --project=smoke --workers=1 --retries=0 --reporter=line
- CI=1 bin/pnpm --dir desktop exec playwright test tests/e2e/scroll-history.spec.ts --project=smoke --grep "channel intro stays hidden while paginating past" --workers=1 --retries=0 --reporter=line
- bin/pnpm --dir desktop exec biome check tests/e2e/scroll-history.spec.ts tests/e2e/channels.spec.ts

Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
The thread inline-expansion smoke test was asserting a fixed top offset for the nested reply row. Current layout places the row lower than that hard-coded value while still fully visible in the thread scroll body and unobscured by the composer, which is the behavior the test needs to protect.

Validation:

- cd desktop && ../bin/biome check tests/e2e/messaging.spec.ts

- CI=1 bin/pnpm --dir desktop exec playwright test tests/e2e/messaging.spec.ts --project=smoke --grep "opens a single-level thread panel" --workers=1 --retries=0 --reporter=line

- CI=1 bin/pnpm --dir desktop exec playwright test tests/e2e/messaging.spec.ts --project=smoke --workers=1 --reporter=line

- CI=1 bin/pnpm --dir desktop exec playwright test --project=smoke --shard=2/4 --workers=1 --reporter=line

Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
The reaction (kind:7) and reaction-removal (kind:5) events carry only an
`e` tag and no channel `h` tag, so the `#h`-scoped aux-backfill query never
returned them. On history reload a removed reaction's deletion tombstone
was never fetched and the reaction reappeared. The `#e` reference is unique
event ids and already fully specific, so the channel scope is redundant for
the aux events that carry it and fatal for those that don't. Add a unit test
asserting both aux filters key on `#e` only.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
@wesbillman wesbillman merged commit 6cca759 into main Jun 20, 2026
44 of 46 checks passed
@wesbillman wesbillman deleted the brain/intro-gate-fix branch June 20, 2026 21:07
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