From bde10b7ae4efc471d0409221dbd66a46e948f63f Mon Sep 17 00:00:00 2001 From: Sy-D <8460326+Sy-D@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:43:52 +0200 Subject: [PATCH 1/2] fix(web): refit the terminal when its container is resized The drawer refit on drawerHeight and a few sibling props, and once from a timer after mount. Nothing watched the container's own size, so dragging a divider or resizing the window left the grid at its mount dimensions: output stayed wrapped to the old column count and the PTY kept the stale size. Observe the container and refit from there, coalescing a drag's callbacks into one fit per frame and reporting to the PTY only when the grid actually moved. The fit-and-pin dance the three call sites repeated is now one helper. Closes #4842. Co-Authored-By: Claude Opus 5 --- .../src/components/ThreadTerminalDrawer.tsx | 59 +++++++++++++++---- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 8591c24c71a..f7750dc09c9 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -93,6 +93,19 @@ function writeTerminalBuffer(terminal: Terminal, buffer: string): void { } } +/** + * Fit to the container and report the resulting grid. Keeps the viewport pinned + * to the bottom when it already was, so a resize never scrolls output away. + */ +function refitTerminal(terminal: Terminal, fitAddon: FitAddon): { cols: number; rows: number } { + const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; + fitTerminalSafely(fitAddon); + if (wasAtBottom) { + terminal.scrollToBottom(); + } + return { cols: terminal.cols, rows: terminal.rows }; +} + function fitTerminalSafely(fitAddon: FitAddon): boolean { try { fitAddon.fit(); @@ -707,13 +720,8 @@ export function TerminalViewport({ const activeTerminal = terminalRef.current; const activeFitAddon = fitAddonRef.current; if (!activeTerminal || !activeFitAddon) return; - const wasAtBottom = - activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; - fitTerminalSafely(activeFitAddon); - if (wasAtBottom) { - activeTerminal.scrollToBottom(); - } - void resizeTerminal(activeTerminal.cols, activeTerminal.rows); + const size = refitTerminal(activeTerminal, activeFitAddon); + void resizeTerminal(size.cols, size.rows); }, 30); return () => { @@ -811,18 +819,43 @@ export function TerminalViewport({ const terminal = terminalRef.current; const fitAddon = fitAddonRef.current; if (!terminal || !fitAddon) return; - const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; const frame = window.requestAnimationFrame(() => { - fitTerminalSafely(fitAddon); - if (wasAtBottom) { - terminal.scrollToBottom(); - } - void resizeTerminal(terminal.cols, terminal.rows); + const size = refitTerminal(terminal, fitAddon); + void resizeTerminal(size.cols, size.rows); }); return () => { window.cancelAnimationFrame(frame); }; }, [drawerHeight, environmentId, resizeEpoch, terminalId, threadId]); + + // The props above only cover resizes the drawer knows about. Dragging a + // divider, resizing the window, or any surrounding reflow changes the + // container without changing them, and the grid would keep its mount size. + useEffect(() => { + const container = containerRef.current; + if (!container) return; + let frame = 0; + const observer = new ResizeObserver(() => { + window.cancelAnimationFrame(frame); + // A drag emits a callback per frame; coalesce them so one gesture costs + // one fit instead of one per pixel. + frame = window.requestAnimationFrame(() => { + const terminal = terminalRef.current; + const fitAddon = fitAddonRef.current; + if (!terminal || !fitAddon) return; + const previous = { cols: terminal.cols, rows: terminal.rows }; + const size = refitTerminal(terminal, fitAddon); + // Pixel changes rarely move the grid; only tell the PTY when they do. + if (size.cols === previous.cols && size.rows === previous.rows) return; + void resizeTerminal(size.cols, size.rows); + }); + }); + observer.observe(container); + return () => { + window.cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, []); return (
Date: Wed, 29 Jul 2026 17:19:59 +0200 Subject: [PATCH 2/2] fix(web): do not refit the terminal while its container is hidden Review feedback: a hidden ancestor collapses the box to 0x0, and the observer fitted to that, handing the PTY a minimal grid. Measured before the guard, a 16-row terminal dropped to 6 while hidden, and the row change defeated the dedup, so the size reached the PTY and the running program reflowed for a viewport nobody could see. Skip the fit when the container has no layout box. Co-Authored-By: Claude Opus 5 --- apps/web/src/components/ThreadTerminalDrawer.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index f7750dc09c9..aa3668f1a01 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -843,6 +843,10 @@ export function TerminalViewport({ const terminal = terminalRef.current; const fitAddon = fitAddonRef.current; if (!terminal || !fitAddon) return; + // A hidden ancestor collapses the box to 0x0, and fitting to that would + // hand the PTY a minimal grid: the running program reflows its output + // for a viewport nobody can see, and keeps it until the drawer returns. + if (container.clientWidth === 0 || container.clientHeight === 0) return; const previous = { cols: terminal.cols, rows: terminal.rows }; const size = refitTerminal(terminal, fitAddon); // Pixel changes rarely move the grid; only tell the PTY when they do.