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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user