From 3904428a4f7fd65a8864a924091b5588e8ecd971 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 8 Oct 2026 20:06:53 +0200 Subject: [PATCH] fix(terminal): keep the terminal resize observer alive across SSE init initTerminal() creates the ResizeObserver on #terminalContainer once per page, and _resetAllAppState() (run by handleInit on EVERY SSE init, page load included) disconnected and dropped it. From the first init on, only a window resize refit the terminal, so anything that resized just the terminal box left xterm at its old row count with the bottom rows clipped behind the toolbar. Pre-existing since the March app.js module split, but this release makes it constant: #538's state rows grow and shrink the header whenever a session starts or stops working, and #544 reserves lineage room when the first child appears. Measured on the beta: header 115 -> 170 px, container 743 -> 688 px, xterm stayed at 35 rows (32 fit) until a tab switch. initTerminal() already disconnects any previous observer before creating one, so the reset has nothing to clean up. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/app.js | 14 ++++-- ...inal-resize-observer-survives-init.test.ts | 46 +++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 test/terminal-resize-observer-survives-init.test.ts diff --git a/src/web/public/app.js b/src/web/public/app.js index f0648fc2..f96281dd 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -4570,11 +4570,15 @@ class CodemanApp { } this.notificationManager.groupingMap.clear(); } - // Disconnect terminal resize observer (prevents memory leak on reconnect) - if (this.terminalResizeObserver) { - this.terminalResizeObserver.disconnect(); - this.terminalResizeObserver = null; - } + // ⚠️ The terminal resize observer is NOT reset here. initTerminal() owns its + // lifecycle (it runs once per page and disconnects any previous observer + // before creating one), and this reset runs on EVERY SSE init, page load + // included. It used to disconnect the observer "to prevent a leak", which + // left the terminal with no observer from the first init on: only a WINDOW + // resize ever refit it, so anything that resized just the terminal box + // (state rows and lineage room appearing in the header, the tab strip + // wrapping) clipped xterm's bottom rows behind the toolbar until a tab + // switch or a window resize. // Clear any other orphaned timers if (this.planLoadingTimer) { clearInterval(this.planLoadingTimer); diff --git a/test/terminal-resize-observer-survives-init.test.ts b/test/terminal-resize-observer-survives-init.test.ts new file mode 100644 index 00000000..3954a222 --- /dev/null +++ b/test/terminal-resize-observer-survives-init.test.ts @@ -0,0 +1,46 @@ +// Port: none (static analysis of the shipped frontend source, no browser/server). +// +// The main terminal refits from a ResizeObserver on #terminalContainer, created +// in initTerminal() (terminal-ui.js), which runs ONCE per page. handleInit() runs +// _resetAllAppState() on EVERY SSE init, page load included, and that reset used +// to disconnect the observer and null it "to prevent a leak on reconnect". So +// from the first init on, the terminal had no observer at all: only a WINDOW +// resize refit it, and anything that resized just the terminal box (the header's +// state rows appearing when a session starts working, the lineage room, the tab +// strip wrapping) left xterm at its old row count, its bottom rows clipped behind +// the toolbar until a tab switch. Measured on the 1.36.0 beta: header 115 -> 170 +// px, container 743 -> 688 px, xterm stayed at 35 rows (32 fit) indefinitely. +// +// initTerminal() already disconnects a previous observer before creating one, +// so the reset has nothing to clean up; it must leave the observer alone. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const read = (f: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', f), 'utf8'); + +function methodBody(source: string, signature: string): string { + const start = source.indexOf(signature); + expect(start, `${signature} not found`).toBeGreaterThan(-1); + // Methods in these files close with a two-space-indented brace at column 2. + const end = source.indexOf('\n }\n', start); + return source.slice(start, end); +} + +describe('the terminal resize observer survives SSE init', () => { + it('_resetAllAppState() does not disconnect or drop terminalResizeObserver', () => { + const body = methodBody(read('app.js'), ' _resetAllAppState(preserveTerminal = false) {'); + expect(body).not.toMatch(/terminalResizeObserver\s*\.\s*disconnect\s*\(/); + expect(body).not.toMatch(/terminalResizeObserver\s*=\s*null/); + }); + + it('initTerminal() owns the lifecycle: disconnects any previous observer, then observes the container', () => { + const body = methodBody(read('terminal-ui.js'), ' initTerminal() {'); + const disconnect = body.indexOf('this.terminalResizeObserver.disconnect()'); + const create = body.indexOf('this.terminalResizeObserver = new ResizeObserver('); + const observe = body.indexOf('this.terminalResizeObserver.observe(container)'); + expect(disconnect).toBeGreaterThan(-1); + expect(create).toBeGreaterThan(disconnect); + expect(observe).toBeGreaterThan(create); + }); +});