From e3e879db9248153b932ffe77c570e23559669213 Mon Sep 17 00:00:00 2001 From: Paul van Dyk Date: Tue, 30 Jun 2026 14:47:56 +0200 Subject: [PATCH] Reflow terminal on layout-driven container resizes The xterm resize path was gated on window.resize / resizeEpoch, so container resizes driven by CSS/flex layout (split-pane drag, sidebar collapse) changed the terminal's DOM size without firing fit() or sending terminalResize. The pty never resized, so no SIGWINCH reached the child. Observe the xterm container with a ResizeObserver (rAF-coalesced to one fit per frame) and refit + send terminalResize directly, instead of relying on window.resize. Also surface resize failures: when a process resize throws (e.g. a Bun build without terminal.resize), the frontend command swallows the error. Log it once per process via Effect.logWarning at the shared resizePtyProcess chokepoint so the cause is visible without spamming. --- apps/server/src/terminal/Manager.ts | 20 +++++++++++- .../src/components/ThreadTerminalDrawer.tsx | 31 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 6347fdfc64d..9cf2203e641 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -200,6 +200,11 @@ interface TerminalSubprocessInspector { ): Effect.Effect; } +// Resize can fail permanently (e.g. a Bun build without `terminal.resize`). The frontend +// command swallows the error, so warn once per process to keep the cause visible in logs +// without spamming on every resize. +const resizeFailureWarned = new WeakSet(); + const resizePtyProcess = ( session: TerminalSessionState, process: PtyAdapter.PtyProcess, @@ -217,7 +222,20 @@ const resizePtyProcess = ( rows, cause, }), - }); + }).pipe( + Effect.tapError((error) => + Effect.gen(function* () { + if (resizeFailureWarned.has(process)) return; + resizeFailureWarned.add(process); + yield* Effect.logWarning("terminal resize failed; size changes will not propagate", { + threadId: session.threadId, + terminalId: session.terminalId, + terminalPid: process.pid, + cause: error.cause, + }); + }), + ), + ); export interface ShellCandidate { shell: string; diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 1641bb6b109..a73a4041286 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -794,6 +794,37 @@ export function TerminalViewport({ window.cancelAnimationFrame(frame); }; }, [drawerHeight, environmentId, resizeEpoch, terminalId, threadId]); + + // CSS/flex-driven container resizes (split-pane drag, sidebar collapse) change the xterm + // size without firing window.resize, so resizeEpoch never advances. Observe the container + // directly and refit. rAF-coalesced to one fit per frame, matching the effect above. + useEffect(() => { + const container = containerRef.current; + if (!container) return; + let frame: number | null = null; + const observer = new ResizeObserver(() => { + if (frame !== null) return; + frame = window.requestAnimationFrame(() => { + frame = null; + const terminal = terminalRef.current; + const fitAddon = fitAddonRef.current; + if (!terminal || !fitAddon) return; + const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; + fitTerminalSafely(fitAddon); + if (wasAtBottom) { + terminal.scrollToBottom(); + } + void resizeTerminal(terminal.cols, terminal.rows); + }); + }); + observer.observe(container); + return () => { + if (frame !== null) { + window.cancelAnimationFrame(frame); + } + observer.disconnect(); + }; + }, [environmentId, terminalId, threadId]); return (