diff --git a/src/web/public/app.js b/src/web/public/app.js index cd95f9da..b495fb8c 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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' diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 09c232cf..1c30ee80 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -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} 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(); diff --git a/test/detached-session-pane-sizing.test.ts b/test/detached-session-pane-sizing.test.ts new file mode 100644 index 00000000..65563691 --- /dev/null +++ b/test/detached-session-pane-sizing.test.ts @@ -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 = vi.fn(); + +function loadTerminalMixin(): Record { + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + const FakeCodemanApp = function () {} as unknown as { prototype: Record }; + 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 = {}) { + 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(), + isSoloWindow: false, + _lastResizeDims: null as { cols: number; rows: number } | null, + _wsReady: false, + _wsSessionId: null as string | null, + ...overrides, + } as Record & { sendResize: (id: string, o?: object) => Promise }; + 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).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 }).fit).not.toHaveBeenCalled(); + }); + + it('the solo window still sizes the session it displays', async () => { + const { app, fetchMock } = makeApp({ isSoloWindow: true }); + (app.detachedSessions as Set).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 }).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); + }); +});