Fullscreen: sticky prompt bar never renders in 2.1.251 (regression from 2.1.246)
Summary
In the fullscreen renderer, scrolling up used to pin the nearest user prompt above the viewport to the top row as a clickable bar (❯ <prompt text>, userMessageBackground, click scrolls back to that prompt). In 2.1.251 it never renders at all. Confirmed as a regression against 2.1.246, which still works.
The component still re-renders while scrolling (the jump-to-bottom pill and the transcript update normally), so it looks like a config problem rather than a bug — I went down several wrong paths before A/B-ing the binaries.
Environment
- Claude Code 2.1.251 (native install,
GIT_SHA 37534ac596d80cefb02d272f036adba4ba055d2c), compared against 2.1.246 "tui": "fullscreen"in~/.claude/settings.json; verified the process really does enter the alt screen (ESC[?1049h) and enable mouse reporting (?1000/1002/1003/1006h)- WSL2 (Ubuntu) under Windows Terminal,
TERM=xterm-256color
Steps to reproduce
- Run with the fullscreen renderer and open a conversation containing several user prompts.
- Scroll up with the mouse wheel until a user prompt has moved fully off the top of the viewport.
Expected: a one-line bar at the top of the conversation pane showing ❯ <that prompt>, clickable to scroll back to it.
Actual (2.1.251): the top row stays empty (it renders as the paddingTop: 1 spacer used when no sticky prompt is present).
A/B evidence
Driven through a pty harness at a fixed 140x40, same resumed conversation, identical SGR wheel-up events, only the binary swapped. Screen reconstructed from the raw output; row 0 shown:
| wheel-up notches | 2.1.246 | 2.1.251 |
| --- | --- | --- |
| 0 (at bottom) | empty | empty |
| 20 | ❯ <prompt text> (with background) | empty |
| 40 | ❯ <earlier prompt text> | empty |
| 60 / 80 | still shown | empty |
| up to 360 (top of transcript) | — | empty at every step |
The Jump to bottom pill renders in both cases, so the viewport is genuinely not sticky in the 2.1.251 run.
Root cause
The sticky-prompt component (mounted as trackStickyPrompt && <…> inside VirtualMessageList) reads three impure values off the scroll viewport, and in 2.1.251 all three sit in React Compiler memo slots keyed on ot.handle:
// 2.1.251
let aS; if (No[2] !== ot.handle) aS = ot.handle?.isSticky() ?? !0, No[2] = ot.handle, No[3] = aS; else aS = No[3];
let Rm = aS;
let lS; if (No[4] !== ot.handle) lS = ot.handle?.getScrollTop() ?? 0, ... else lS = No[5];
let cS; if (No[6] !== ot.handle) cS = ot.handle?.getPendingDelta() ?? 0, ... else cS = No[7];
let pr = Math.max(0, lS + cS);
ot.handle is the scroll box's ink node — stable for the lifetime of the box. So these three reads are captured once, at first attach, when the view is still pinned to the bottom: isSticky() === true, scrollTop === 0. They never update again, and the guard
if (Go > 0 && !Rm) { /* walk up to find the nearest prompt above the viewport */ }
can therefore never run, leaving setStickyPrompt(null) as the only reachable outcome.
useSyncExternalStore(ot.subscribe, …) still re-renders the component on every scroll — the stale values are the whole problem, not a missing re-render.
For comparison, 2.1.246 read the same three values inline on every render, with no memo:
// 2.1.246
let g = a.current?.isSticky() ?? !0,
x = Math.max(0, (a.current?.getScrollTop() ?? 0) + (a.current?.getPendingDelta() ?? 0));
Of the versions I still had on disk (2.1.243 / 2.1.245 / 2.1.246 / 2.1.251), only 2.1.251 contains the scrollViewport props; 2.1.243–2.1.246 still use scrollRef. So this came in with the scrollRef → scrollViewport rewrite somewhere in 2.1.247–2.1.251 — I can't narrow it further because I no longer have those builds.
Suggested fix
Read isSticky() / getScrollTop() / getPendingDelta() outside the memo cache (or key them on the store snapshot that useSyncExternalStore already produces) rather than on ot.handle. Worth grepping for other impure ot.handle?.…() reads that the compiler may have memoized the same way during that rewrite.
Workarounds
None found from the user side. Resizing the terminal does not help — it doesn't change the ink node's identity, so the memo still never invalidates.