From 3859506f9b6e30f4f63bfc2c8e95043d051c365e Mon Sep 17 00:00:00 2001 From: timkjr Date: Fri, 18 Sep 2026 20:55:09 -0500 Subject: [PATCH] fix(split-pane): populate Pane B history and force-resize Pane A on split changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pane B's SplitTerminalPane.connect() only opened a WebSocket and waited for live output — ws-routes.ts's terminal socket sends nothing on connect, only future 'terminal' events — so it stayed blank until the target session happened to produce new output. It looked intermittent rather than always-broken because a resize sent by _sendResize() often nudges the session's real tmux window to a new size, and tmux repaints its current screen on resize; that incidental repaint was what usually populated the pane. When Pane B's computed dimensions already matched the session's last-known size, Session.resize() skipped the resize as a no-op and the pane stayed empty. Fetch the existing scrollback (?full=1) before opening the socket, same as the primary pane does. Pane A never told its own session's PTY/tmux about a size change at all, relying purely on the passive 300ms-debounced ResizeObserver in terminal-ui.js. openSplitPane() now force-resizes Pane A immediately on entering split (mirroring closeSplitPane()'s existing symmetric call), and the divider-drag handler force-resizes it once at drag end (matching the codebase's established trailing-edge debounce convention rather than flooding a resize per mousemove). Co-Authored-By: Claude Sonnet 5 --- src/web/public/terminal-split.js | 45 +++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/web/public/terminal-split.js b/src/web/public/terminal-split.js index 252daf89..d39c5b96 100644 --- a/src/web/public/terminal-split.js +++ b/src/web/public/terminal-split.js @@ -21,9 +21,10 @@ this.fitAddon = null; this.ws = null; this._wsReady = false; + this._destroyed = false; } - connect() { + async connect() { this.terminal = new Terminal({ theme: { ...global.codemanCurrentXtermTheme() }, fontFamily: global.CodemanTerminalFont.resolve(), @@ -49,6 +50,28 @@ } }); + // Load existing scrollback before going live. The WS below is + // subscribe-only (ws-routes.ts sends nothing on connect, only future + // 'terminal' events), so without this Pane B stays blank until the + // target session happens to produce new output. It LOOKED + // intermittent rather than always-broken because _sendResize() below + // often nudges the shared session's real tmux window to a new size, + // and tmux repaints its current screen on resize — that repaint was + // getting captured and streamed here, incidentally populating the + // pane. When Pane B's computed dimensions happened to already match + // the session's last-known size, Session.resize() (session.ts) skips + // the resize as a no-op, no repaint fires, and the pane stayed blank. + try { + const res = await fetch(`${window.CodemanBase.base}/api/sessions/${this.sessionId}/terminal?full=1`); + const payload = (await res.json())?.data ?? {}; + if (payload.terminalBuffer && this.terminal) { + this.terminal.write(payload.terminalBuffer); + } + } catch { + /* Best-effort — live output still arrives once the socket below connects. */ + } + if (this._destroyed) return; + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`; this.ws = new WebSocket(url); @@ -106,6 +129,7 @@ } destroy() { + this._destroyed = true; if (this.ws) { this.ws.onopen = null; this.ws.onmessage = null; @@ -255,6 +279,14 @@ Object.assign(CodemanApp.prototype, { this._splitPane.connect(); this._splitSessionId = sessionId; + // Pane A just went from full width to 50%, but nothing has told its + // session's PTY/tmux window about it yet — the passive ResizeObserver in + // terminal-ui.js debounces 300ms and would eventually catch up, but + // relying on that left the pane showing stale-width content (existing + // box-drawing lines, banners) until the user hit "Redraw Terminal". + // Force it immediately, mirroring closeSplitPane()'s symmetric call. + this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {}); + this._installSplitDividerDrag(divider, wrap, paneB); }, @@ -296,6 +328,17 @@ Object.assign(CodemanApp.prototype, { divider.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); + // Pane B force-resizes on every move via _splitPane.fit() (SplitTerminalPane + // has no client-side "dims unchanged" skip, so it always reaches the + // server). Pane A's onMove above only calls fitAddon.fit() — a LOCAL + // xterm reflow that changes how many columns xterm displays but never + // tells Pane A's own PTY/tmux window the new size, so existing content + // (box-drawing lines, banners) stays laid out for the old width. Fire + // once here, at drag end, rather than per-move (matching the codebase's + // established trailing-edge debounce convention — see throttledResize + // in terminal-ui.js — so a fast drag doesn't flood dozens of + // intermediate SIGWINCH/reflow states into scrollback). + this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {}); }; divider.addEventListener('mousedown', () => {