fix(tui): stop the scrollbar from blanking the transcript on resize - #775
Merged
Conversation
Resizing the terminal blanked the whole transcript: composer and footer kept
painting, the transcript region went empty, and it never came back.
TranscriptScrollbar is a ROW SIBLING of the transcript ScrollBox and renders
exactly `viewportHeight` rows tall — where that value is LAST frame's
scrollViewportHeight, written by render-node-to-output at paint time. A flex row
stretches its children to the tallest one, so a stale-tall bar stretches the
ScrollBox; its inner wrapper (flexGrow:1) fills the extra with blank rows; and
the stretched height becomes the next frame's scrollViewportHeight, which
re-renders the bar at that same height. A stable fixed point, not a transient.
Widening re-wraps every row shorter, so the gap opens exactly then. Measured on
a PTY at 60 -> 110 columns: content fell 161 -> 102 rows while the ScrollBox
stayed at 161. Those ~59 blank rows push the transcript above the terminal
viewport, where the resize repaint's ERASE_SCROLLBACK (log-update fullReset ->
clearTerminal) wipes it from scrollback — so it is gone, not merely scrolled off.
Inline mode is the default and has no constrained-height root, so the ScrollBox
always fits its content and `total <= vp` holds: the bar only ever took its
!scrollable branch and painted a column of invisible SPACES. Suppress it there.
The 1-column gutter stays so transcriptPanelWidth's reservation still matches
what is drawn. Fullscreen pins the root to terminalRows and cannot drift, and
the bar is real UI there, so it is untouched.
OverlayScrollbar in agentsOverlay.tsx has the identical shape and the same bug;
it gets the same guard. Its minHeight={0} does not help — that permits shrinking
below content, it does not stop cross-axis stretch.
Ruled out by experiment, recorded so nobody retries them: recursively marking
every Yoga node dirty before calculateLayout (so this is NOT a layout-cache
bug), minHeight={0} on the ScrollBox, and minHeight={0} on the ScrollBox plus
both ancestor flex boxes.
The regression test asserts ROWS, not glyphs — the inline case paints spaces, so
"draws no bar characters" passes against the bug. It self-calibrates against a
bar-free render and waits for a settled frame rather than a fixed timeout, which
would fail open. Mutation-tested: reverting the guard fails 2 of its 3 cases.
Also comments the three useSyncExternalStore call sites whose stable deps are
only safe because the ScrollBox never remounts — remounting one orphans every
subscription on the dead handle, which is why keying it on `cols` is not a fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Test Results 1 files 1 suites 7m 59s ⏱️ Results for commit df7f823. |
ericleepi314
added a commit
that referenced
this pull request
Aug 3, 2026
An earlier attempt at the resize-blanking bug keyed the transcript ScrollBox on `cols` to force a fresh Yoga node. It was abandoned once the real cause turned out to be TranscriptScrollbar stretching the row from a stale viewportHeight (fixed in #775), but it had already been swept into #774 and reached main. It is not merely redundant now, it is harmful. Remounting the ScrollBox builds a new listenersRef Set behind useImperativeHandle(..., []), while the three useSyncExternalStore subscribers (useVirtualHistory, useViewportSnapshot, useScrollbarSnapshot) have stable deps and never resubscribe — so every subscription is orphaned on the dead handle, on every resize. It also resets the handle, sending useVirtualHistory down its `vp <= 0` cold-start branch, which on a long transcript mounts only the last 30 items behind a large blank topSpacer. Its comment was also wrong about the mechanism, which is worse than no comment: it told the next reader the vendored Yoga port fails to re-measure and that marking the tree dirty is insufficient. Neither is true. Verified the resize fix still holds without it: PTY at 60 -> 110 columns, blank rows 7/30 before and after, matching #775's numbers. Suite unchanged from main's baseline at 8 failed / 1681 passed across three runs. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.
Resizing the terminal blanked the whole transcript — composer and footer kept painting, the transcript region went empty, and it never came back. Reported in the VS Code integrated terminal.
Root cause
TranscriptScrollbaris a row sibling of the transcript ScrollBox and renders exactlyviewportHeightrows tall — and that value is last frame'sscrollViewportHeight, written byrender-node-to-outputat paint time.A flex row stretches its children to the tallest one, so a stale-tall bar stretches the ScrollBox; its inner wrapper (
flexGrow:1) fills the extra with blank rows; and the stretched height becomes the next frame'sscrollViewportHeight, which re-renders the bar at that same height. A stable fixed point, not a transient — which is why it never recovers.Widening re-wraps every row shorter, so the gap opens exactly then. Measured on a PTY at 60 → 110 columns: content fell 161 → 102 rows while the ScrollBox stayed at 161. Those ~59 blank rows push the transcript above the terminal viewport, where the resize repaint's
ERASE_SCROLLBACK(log-updatefullReset→clearTerminal) wipes it from scrollback.Fix
if (!vp)→if (!vp || INLINE_MODE)in both scrollbars.Inline mode is the default and has no constrained-height root, so the ScrollBox always fits its content and
total <= vpholds — the bar only ever took its!scrollablebranch and painted a column of invisible spaces, 40 rows of them. The 1-column gutter stays, sotranscriptPanelWidth's reservation still matches what's drawn. Fullscreen pins the root toterminalRows, can't drift, and the bar is real UI there — untouched.OverlayScrollbar(agentsOverlay.tsx) has the identical shape and the same bug, so it gets the same guard. ItsminHeight={0}doesn't help: that permits shrinking below content, it doesn't stop cross-axis stretch.Ruled out by experiment
Recorded so nobody retries them:
calculateLayout— so this is not a layout-cache bug.minHeight={0}on the ScrollBox.minHeight={0}on the ScrollBox plus both ancestor flex boxes.colsto force a remount. It fixes the symptom but orphans threeuseSyncExternalStoresubscriptions on the dead handle and cold-starts the virtualizer. The three call sites now carry a comment explaining why their stable deps are only safe while the ScrollBox never remounts.Verification
main's baseline is 8 failed / 1605 passed — same 8 pre-existing failures, +3 from this PR, no flake.tsc --noEmitclean, eslint clean on all changed files.Follow-up, not in this PR
ERASE_SCROLLBACK(ESC[3J) fires on every resize (log-update.ts:188→clearTerminal.ts:63). In inline mode a full repaint needs onlyESC[2J+ cursor home; theESC[3Jadditionally destroys the user's pre-TUI shell scrollback and every transcript row that scrolled off, on every window drag. It survives this fix — it's what upgraded "scrolled off screen" to "unrecoverable".🤖 Generated with Claude Code