Skip to content

fix(tui): keep viewport steady when revealing older transcript rows - #54158

Closed
opencode-agent[bot] wants to merge 1 commit into
devfrom
tui-user-accent-flicker
Closed

opencode-agent[bot] wants to merge 1 commit into
devfrom
tui-user-accent-flicker

Conversation

@opencode-agent

@opencode-agent opencode-agent Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Issue for this PR

No separate issue. Reported in Slack with a screen recording.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Scrolling up through a long session sometimes flashes a different part of the transcript for one frame. In the report, it showed the session's first long prompt, so the left accent bar seemed to flicker.

The transcript mounts only its newest rows. When the reader scrolls near the top of the mounted range, revealOlderRows mounts an older chunk above it. It then calls scrollBy(heightDelta) inside afterLayout, which runs two animation frames later. Until then, the old scrollTop is drawn over the taller content, so the viewport briefly shows the top of the new chunk.

The fix pins the current first mounted row through the existing held/holdAnchor mechanism already used for group toggles. The content resize fires during layout, so holdAnchor corrects scrollTop in the same frame. The afterLayout callback reapplies the anchor once, releases the hold, and then applies the pending wheel delta as before.

The history-page path (createHistoryPrepend in history.ts) uses the same two-frame correction. That path waits on a network request, so an anchor held for the whole request could conflict with the reader's own scrolling. This PR leaves it unchanged.

How did you verify your code works?

  • Added test/session-reveal-older-rows.test.tsx. It records every rendered frame while scrolling up through 200 messages and fails if the top visible message jumps by more than two between frames. It fails on v2 with a one-frame jump to a distant message and back, and passes with this change.
  • bun test test/session-*.test.tsx: 36 pass.
  • bun run typecheck in packages/tui.
  • Real PTY check: I ran the TUI with a 120-turn session, sent the same 70 wheel-up events to v2 and this branch, and replayed both recordings every 2 ms. On v2, the top prompt went 109 → 89 → 109 for about 14 ms when the older chunk mounted. On this branch, the top prompt decreased steadily over the same range (118 → 97) with no jumps.

Screenshots / recordings

The side-by-side before/after recording (real time plus 15x slow motion) is in the Slack thread. I could not attach it here.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Requested by: @nexxeln (nexxel via Slack)


Added by the opencode bot (AI-generated).

Related: #45976

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The flash is fixed. The new test fails on v2 with a one-frame jump and passes here. bun test test/session-*.test.tsx (36 pass) and typecheck in packages/tui pass too.

Public API is unchanged.

One regression is worth fixing before merge: the extra holdAnchor() in the afterLayout callback throws away wheel input that arrives during the two-frame window and snaps the viewport back. Details and a measured repro are inline.

Related: #45976

setHiddenRows(rowsBefore(weights(), current, TRANSCRIPT_BACKFILL_CHUNK))
afterLayout(() => {
scroll.scrollBy(scroll.scrollHeight - before + scrollBy)
if (hold && held === hold) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Calling holdAnchor() again here sets an absolute scrollTo, which drops any wheel scrolling the reader did in the two frames since the content resize. The resize handler had already corrected scrollTop. Repro: I scrolled until a chunk mounted, then sent 4 more wheel-ups before this callback ran. On v2, scrollTop goes 6 → 246 (wheel input kept). On this branch it goes 261 → 249 → 261: the reader's 12 rows are lost and the view jumps back down.

Just releasing the hold (if (hold && held === hold) held = undefined) keeps the wheel input (ends at 249), and your new test still passes.

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Automated PR Cleanup

Thank you for contributing to opencode.

Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions.

This PR was closed because it matched the following cleanup criteria:

  • The PR was created more than 1 month ago
  • The PR had fewer than 2 positive reactions
  • Positive reactions are counted as thumbs-up, heart, celebration, or rocket reactions on the PR

PRs created within the last month are not affected by this cleanup.

If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate.

Thanks again for taking the time to contribute.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant