fix(tui): refresh virtual transcript on viewport resize (#31077)

* fix(tui): refresh virtual transcript on viewport resize

Notify scroll subscribers when ScrollBox viewport bounds change and key virtual-history updates on viewport height so resize/keyboard changes remount the tail rows instead of leaving stale spacers visible.

* test(tui): isolate viewport-height remount regression

Keep the resize delta below the virtual history scroll quantum so the regression test specifically depends on viewport height entering the snapshot key.

* test(tui): clarify virtual history resize snapshot

Update the resize regression and comments so the test specifically guards viewport-height changes in the virtual-history snapshot key.

* docs(tui): clarify scrollbox subscription signals

Document that ScrollBox subscribers are notified for renderer-computed viewport and content bound changes, not only imperative scrolls.

* fix(tui): recompute virtual tail after width resize

Avoid preserving a frozen virtual transcript range when wrapped rows shrink enough that the old tail window no longer covers the viewport.

* fix(tui): preserve transcript tail across resizes

Wraps + heights are column-dependent, so a width change must remeasure
every row and the renderer must repaint the full viewport.

- Key virtualRows on cols so React remounts wrapped rows on resize.
- Snap back to bottom after sticky-mode resize once React rerenders.
- Reserve a scrollbar + gap column in transcriptBodyWidth (non-termux).
- Full repaint on any viewport height change (was: shrink-only).
- ScrollBox scrollHeight uses deepest child bottom so sticky-bottom
  math can reach the real final rendered row after reflow.
- DECSTBM fast-path now requires full container rect match.

* feat(tui): responsive banner tiers

Terminals can't scale glyphs, so the banner now picks a layout per
column width instead of always rendering the full 101-col logo:

- Wide (>= logo width): full ASCII logo + tagline.
- Mid (>= 58 cols): centered rule banner that expands with viewport.
- Narrow (>= 34 cols): brand line + tagline, both width-aware.
- < 34 cols: hidden.

SessionPanel surfaces model/cwd/sid inline when the hero column is
hidden, so narrow layouts don't lose that info. Logo width constants
derive from the art itself.

* fix(tui): re-check sticky inside resize debounce + document remount

Addresses Copilot review on PR #31077:

- onResize now re-checks isSticky() inside the 100ms timer so manual
  scrolls during the debounce window don't get snapped back to tail.
- Comment on the virtualRows cols-keying calls out the deliberate
  trade-off: per-row local state (e.g. systemOpen) resets on resize so
  yoga can remeasure off live geometry. The hook's scale-by-ratio path
  is too approximate for mixed markdown widths.
This commit is contained in:
brooklyn!
2026-05-23 19:39:53 -05:00
committed by GitHub
11 changed files with 367 additions and 77 deletions

View File

@@ -234,9 +234,15 @@ export function useMainApp(gw: GatewayClient) {
return next
}, [])
// Wrapped row heights are width-dependent. Cached layout outlives a resize
// and lands sticky-scroll at the stale max, cutting off the tail. The
// hook's "scale heights by oldCols/newCols" path is too approximate for
// mixed markdown — we deliberately remount every row so yoga re-measures
// off live geometry. Cost: per-row local state (e.g. systemOpen toggles)
// resets on resize; small UX hit for a hard correctness win.
const virtualRows = useMemo<TranscriptRow[]>(
() => historyItems.map((msg, index) => ({ index, key: messageId(msg), msg })),
[historyItems, messageId]
() => historyItems.map((msg, index) => ({ index, key: `${messageId(msg)}:c${cols}`, msg })),
[cols, historyItems, messageId]
)
const detailsLayoutKey = useMemo(() => {
@@ -425,10 +431,20 @@ export function useMainApp(gw: GatewayClient) {
let timer: ReturnType<typeof setTimeout> | undefined
// Resize reflows wrapped lines; if the user is still pinned to the tail
// we need to re-snap once React has remeasured. virtualRows is keyed on
// cols so every column change forces a fresh measurement pass before
// this timer fires. Re-check isSticky() inside the timeout — a manual
// scroll during the 100ms window otherwise yanks the user back to tail.
const onResize = () => {
clearTimeout(timer)
timer = setTimeout(() => {
timer = undefined
if (scrollRef.current?.isSticky()) {
scrollRef.current.scrollToBottom()
}
void rpc<TerminalResizeResponse>('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid })
}, 100)
}