fullscreen sticky prompt header stopped rendering in 2.1.247 (regression: memoized isSticky() never re-evaluates)
The scroll-aware sticky prompt header — the dimmed one-line header pinned at the top of the viewport showing the user prompt that owns the currently visible output, clickable to jump back to that message — shipped and worked through 2.1.246, and stopped rendering in 2.1.247. It is still broken in 2.1.250.
(This is the feature requested in #75845, which is still open — it was in fact implemented and working.)
Environment
- macOS 26 (Darwin 25.6.0), Apple Silicon
- Native install, fullscreen renderer (
"tui": "fullscreen") - Ghostty-based terminal
What happens
Scroll up in fullscreen mode so a user prompt moves above the viewport:
- 2.1.246 and earlier: row 1 shows the dimmed prompt line.
- 2.1.247 / 2.1.250: row 1 is blank.
The "Jump to bottom" pill still appears at the same scroll position, so the scroll state itself is detected correctly — only the sticky header is missing.
Reproduction
- Set
"tui": "fullscreen". - Send a normal text prompt whose reply is longer than one screen, e.g.
print numbers 1 to 55, one per line, digits only, no commentary. - Press PageUp twice, so the prompt scrolls above the viewport.
- Expected: dimmed prompt pinned at row 1. Actual: row 1 is blank.
Note: prompts entered as ! bash commands are not sticky candidates — that part appears to be by design. Reproduce with normal text prompts.
Bisect
Resumed one identical session (same transcript, same token count, same scroll position reached with the same keystrokes) under each build downloaded from https://downloads.claude.ai/claude-code-releases/<version>/darwin-arm64/claude, with checksums verified against each manifest.json:
| version | sticky header |
|---|---|
| 2.1.241 | works |
| 2.1.243 | works |
| 2.1.246 | works |
| 2.1.247 | broken |
| 2.1.250 | broken |
In every run the visible transcript rows were identical and the "Jump to bottom" pill was present; only row 1 differed.
Root cause
In 2.1.246 the sticky-prompt component read the viewport on every render:
let g = a.current?.isSticky() ?? true;
// ...
if (R > 0 && !g) { /* scan backwards for the nearest user prompt */ }
In 2.1.247 the same block was rewritten with compiler-style memoization keyed on the viewport handle object:
let mM;
if (xo[2] !== Oe.handle) { mM = Oe.handle?.isSticky() ?? true; xo[2] = Oe.handle; xo[3] = mM }
else mM = xo[3];
let Xg = mM;
// ...
if (qo > 0 && !Xg) { /* scan backwards for the nearest user prompt */ }
Oe.handle keeps the same object identity for the lifetime of the list, so isSticky() is evaluated once at mount — while the view is still pinned to the bottom, returning true — and that cached true is reused for every subsequent render. !Xg is therefore permanently false and the prompt-scan loop is never entered.
getScrollTop() and getPendingDelta() are memoized on the same key, so the derived scroll offset is frozen at its mount-time value as well.
The scroll subscription still fires re-renders, but because the memo key never changes, each re-render reuses the stale cached values.
2.1.250 keeps the identical pattern (Io[2] !== ot.handle).
The "Jump to bottom" pill is unaffected because it reads handle.isSticky() directly inside a callback rather than through the memo — which is why scroll state looks correctly detected while the header is missing.
Suggested fix
Don't memoize the live viewport reads on the handle identity. Key them on the scroll position/tick that the subscription already provides, or read them unmemoized as 2.1.246 did.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗