fix(terminal): let a detached session's own window own its pane size

Popping a session out left both windows sizing the same pane. The dashboard
keeps the session active and keeps measuring it, and its terminal is
narrower than the popup because the session rail takes width the popup does
not have. One PTY cannot hold two sizes, so the CLI drew frames that fit
neither window and the popup showed a garbled frame.

sendResize and the debounced window-resize handler now stand aside for a
session this window has marked detached. A solo window is exempt, since it
is the owner. _maybeRefetchFullHistory already stood aside on exactly this
condition, so the rule is not a new one.

Sizing has to come back when the popup closes: while it owned the session
the dashboard sent no resizes, so the PTY still holds the popup's geometry.
_redock now re-asserts, with force, because the dimensions the dashboard
last sent are the ones it is about to send again.

Reproduced with a dashboard and a popup on one session: before, the pane
sat at 315 columns while the popup rendered 289. After, both report the
same size and the popup's frame matches the pane exactly.
This commit is contained in:
Michael Grundberg
2026-09-09 14:20:34 +02:00
parent 5130ca6633
commit 5ac516dd3b
3 changed files with 118 additions and 1 deletions
+8
View File
@@ -1340,6 +1340,14 @@ class CodemanApp {
this._detachOrphanStrikes.delete(id);
this.detachedWindows.delete(id);
this._markDetached(id, false);
// Sizing comes back with the session. While the popup owned it the dashboard
// sent no resizes, so the PTY still holds the popup's geometry; the session
// this window is actually showing has to be re-sized to this window, and
// `force` is required because the dimensions the dashboard last sent are
// the ones it is about to send again.
if (id === this.activeSessionId) {
this.sendResize(id, { force: true })?.catch?.(() => {});
}
}
/** Defer a channel-driven redock briefly. A popup *reload* emits 'redocked'
+11 -1
View File
@@ -935,7 +935,10 @@ Object.assign(CodemanApp.prototype, {
// causes Ink to re-render at the new row count, garbling terminal output.
// Local fit() still runs so xterm knows the viewport size for scrolling.
const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
if (this.activeSessionId && !keyboardUp) {
// Same yield as sendResize: never resize a PTY whose session is showing
// in its own window. Dragging the dashboard's border must not reshape it.
const detachedElsewhere = !this.isSoloWindow && this.detachedSessions?.has(this.activeSessionId);
if (this.activeSessionId && !keyboardUp && !detachedElsewhere) {
const dims = this.fitAddon.proposeDimensions();
// Enforce minimum dimensions to prevent layout issues
const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS;
@@ -4824,6 +4827,13 @@ Object.assign(CodemanApp.prototype, {
* @returns {Promise<boolean>} Whether dimensions changed from the last send
*/
async sendResize(sessionId, options = {}) {
// One PTY cannot hold two sizes. A detached session is owned by its own
// window, and the dashboard's terminal is narrower than that window because
// the session rail takes width the popup does not have — so both sizing it
// makes the CLI draw frames that fit neither, which garbles the popup. The
// dashboard yields; the solo window sizes what it alone displays.
// (_maybeRefetchFullHistory already stands aside for the same reason.)
if (!this.isSoloWindow && this.detachedSessions?.has(sessionId)) return false;
// Fit terminal to container before reading dimensions — ensures local
// terminal size matches what we report to the server PTY.
if (this.fitAddon) this.fitAddon.fit();
+99
View File
@@ -0,0 +1,99 @@
/**
* @fileoverview A detached session's pane is sized by its own window, not by
* the dashboard.
*
* One PTY holds one size. When a session is popped out, the dashboard keeps it
* active and keeps measuring it, but the dashboard's terminal is narrower than
* the popup because the session rail takes width the popup does not have. Both
* windows sizing the same pane makes the CLI draw frames that fit neither, and
* the popup shows the result as a garbled frame.
*
* `sendResize` therefore returns early for a session this window has marked
* detached, and the debounced window-resize handler skips it for the same
* reason. A solo window is exempt: it IS the owner. `_maybeRefetchFullHistory`
* already stood aside on the same condition, so this follows a rule the code
* had already established.
*
* Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this
* box; see connection-indicator.test.ts), the same way terminal-buffer-flush
* extracts the real mixin methods from terminal-ui.js.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
/** The mixin runs inside the vm context, so its `fetch` must live there too. */
let currentFetch: ReturnType<typeof vi.fn> = vi.fn();
function loadTerminalMixin(): Record<string, unknown> {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
const context = vm.createContext({
console,
performance,
setTimeout,
clearTimeout,
setInterval: vi.fn(),
clearInterval: vi.fn(),
requestAnimationFrame: vi.fn(),
CodemanApp: FakeCodemanApp,
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), innerWidth: 1600 },
document: { addEventListener: vi.fn() },
fetch: (...args: unknown[]) => currentFetch(...args),
});
vm.runInContext(source, context);
return FakeCodemanApp.prototype;
}
const mixin = loadTerminalMixin();
const SESSION = 'session-A';
function makeApp(overrides: Record<string, unknown> = {}) {
const fetchMock = vi.fn(async () => ({ json: async () => ({ data: { changed: true } }) }));
currentFetch = fetchMock;
const app = {
sendResize: mixin.sendResize,
getTerminalDimensions: () => ({ cols: 120, rows: 40 }),
fitAddon: { fit: vi.fn() },
detachedSessions: new Set<string>(),
isSoloWindow: false,
_lastResizeDims: null as { cols: number; rows: number } | null,
_wsReady: false,
_wsSessionId: null as string | null,
...overrides,
} as Record<string, unknown> & { sendResize: (id: string, o?: object) => Promise<boolean> };
return { app, fetchMock };
}
describe('detached sessions own their pane size', () => {
it('the dashboard does not resize a session showing in its own window', async () => {
const { app, fetchMock } = makeApp();
(app.detachedSessions as Set<string>).add(SESSION);
const changed = await app.sendResize(SESSION);
expect(changed).toBe(false);
// No measurement and no request: the popup's size stands.
expect(fetchMock).not.toHaveBeenCalled();
expect((app.fitAddon as { fit: ReturnType<typeof vi.fn> }).fit).not.toHaveBeenCalled();
});
it('the solo window still sizes the session it displays', async () => {
const { app, fetchMock } = makeApp({ isSoloWindow: true });
(app.detachedSessions as Set<string>).add(SESSION);
await app.sendResize(SESSION);
// The popup is the owner, so being marked detached must not stop it.
expect(fetchMock).toHaveBeenCalledTimes(1);
expect((app.fitAddon as { fit: ReturnType<typeof vi.fn> }).fit).toHaveBeenCalled();
});
it('the dashboard resizes a session that is not detached', async () => {
const { app, fetchMock } = makeApp();
await app.sendResize(SESSION);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});