From 378c840509d900718ac1f836582091a163208015 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 29 Jul 2026 11:41:15 +0300 Subject: [PATCH 1/2] feat(terminal): add explicit viewport sizing ownership --- src/session.ts | 66 +++++++-------- src/web/routes/session-routes.ts | 8 +- src/web/routes/ws-routes.ts | 32 +++++-- src/web/schemas.ts | 1 + test/mocks/mock-session.ts | 1 - test/routes/session-routes.test.ts | 14 +++ test/routes/ws-routes.test.ts | 108 ++++++++++++++++++++++++ test/session-resize-arbitration.test.ts | 75 +++++++++++++--- 8 files changed, 246 insertions(+), 59 deletions(-) diff --git a/src/session.ts b/src/session.ts index 98e05940..9a0af0be 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2592,12 +2592,10 @@ export class Session extends EventEmitter { /** * Live WebSocket connections that have announced a desktop viewport for this - * session. While at least one is registered, small-viewport (mobile/tablet) - * resizes are ignored so a phone glancing at the session can't reflow the - * PTY under an active desktop view. Claims are connection-scoped: ws-routes - * registers them on a desktop-typed resize and releases them on socket - * close, so a mobile-only session (no desktop connected) keeps full control - * of its own size — including narrowing below the spawn default. + * session. Presence and activity are deliberately separate: reconnecting a + * background desktop may register here, but only explicit interaction/input + * refreshes _lastDesktopActivityAt. This prevents an automatic page restore + * from taking sizing control away from an active phone. * * Deliberate tradeoff: claims are WS-only because only a socket has a * liveness signal. A desktop degraded to the stateless HTTP resize fallback @@ -2609,7 +2607,7 @@ export class Session extends EventEmitter { /** * A desktop sizing claim only blocks small-viewport resizes while the - * desktop is RECENTLY ACTIVE (claim registration or typed input within this + * desktop is RECENTLY ACTIVE (explicit sizing takeover or typed input within this * window). An abandoned-but-connected desktop tab (left open at home, screen * locked) must not hold a phone's view hostage: without this, the phone * renders a desktop-width stream in a narrow xterm — mid-word wraps, tmux @@ -2617,19 +2615,15 @@ export class Session extends EventEmitter { */ private static readonly DESKTOP_CLAIM_IDLE_MS = 90_000; - /** Last evidence of a live desktop user (claim registered / typed input). */ + /** Last evidence of explicit desktop interaction or typed input. */ private _lastDesktopActivityAt = 0; - /** Last desktop-typed dimensions, for re-asserting after a mobile override. */ - private _lastDesktopDims: { cols: number; rows: number } | null = null; - /** True while a small viewport reflowed the pane past an idle desktop claim. */ private _mobileSizeOverride = false; - /** Register a live desktop sizing claim (see _desktopSizeClaims). */ + /** Register live desktop presence without treating a reconnect as user activity. */ claimDesktopSizing(token: symbol): void { this._desktopSizeClaims.add(token); - this._lastDesktopActivityAt = Date.now(); } /** Release a desktop sizing claim when its connection goes away. */ @@ -2637,50 +2631,48 @@ export class Session extends EventEmitter { this._desktopSizeClaims.delete(token); } - /** - * Record desktop user activity (typed input over a claim-holding socket). - * If a phone reflowed the pane while the desktop was idle, the desktop - * layout is restored — "whoever is actively using the session wins". - */ - noteDesktopActivity(): void { - this._lastDesktopActivityAt = Date.now(); - if (this._mobileSizeOverride && this._lastDesktopDims) { - this._mobileSizeOverride = false; - this.resize(this._lastDesktopDims.cols, this._lastDesktopDims.rows, { viewportType: 'desktop' }); - } - } - /** * Resizes the PTY terminal dimensions. * Skips the resize if dimensions haven't changed to avoid triggering * unnecessary Ink full-screen redraws (visible flicker on tab switch). * * Arbitration: while a desktop connection holds a sizing claim AND has been - * active within DESKTOP_CLAIM_IDLE_MS, resizes from small viewports + * active within DESKTOP_CLAIM_IDLE_MS, passive resizes from small viewports * (mobile/tablet) are ignored — shrink AND grow would both reflow the - * desktop view. Once the desktop goes idle, a phone may take the pane (the - * desktop re-asserts its size on its next typed input via - * noteDesktopActivity). Without a desktop connected, small viewports - * control the PTY size freely. + * desktop view. A trusted interaction can explicitly take control. Once the + * desktop goes idle, a phone may also take the pane passively (the desktop + * re-asserts its size on its next interaction). Without a desktop connected, + * small viewports control the PTY size freely. * * @param cols - Number of columns (width in characters) * @param rows - Number of rows (height in lines) */ - resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType; force?: boolean } = {}): void { + resize( + cols: number, + rows: number, + options: { viewportType?: ResizeViewportType; force?: boolean; takeControl?: boolean } = {} + ): void { + const isDesktopViewport = options.viewportType === 'desktop'; const isSmallViewport = options.viewportType === 'mobile' || options.viewportType === 'tablet'; - if (options.viewportType === 'desktop') { - this._lastDesktopDims = { cols, rows }; - this._lastDesktopActivityAt = Date.now(); + const restoresMobileOverride = isDesktopViewport && this._mobileSizeOverride && options.takeControl === true; + if (isDesktopViewport) { + // Passive desktop restores must not displace a phone that explicitly + // took control of the shared PTY. + if (this._mobileSizeOverride && !options.takeControl) return; + if (options.takeControl) this._lastDesktopActivityAt = Date.now(); this._mobileSizeOverride = false; } + if (isSmallViewport && options.takeControl) { + this._mobileSizeOverride = true; + } if (isSmallViewport && this._desktopSizeClaims.size > 0) { - if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) { + if (!options.takeControl && Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) { return; } this._mobileSizeOverride = true; } const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows; - if (this.ptyProcess && (dimsChanged || options.force)) { + if (this.ptyProcess && (dimsChanged || options.force || restoresMobileOverride)) { this._ptyCols = cols; this._ptyRows = rows; if (this._mux && this._muxSession) { diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 20636f8a..e53a74ee 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -931,10 +931,14 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/resize', async (req) => { const { id } = req.params as { id: string }; - const { cols, rows, viewportType, force } = parseBody(ResizeSchema, req.body); + const { cols, rows, viewportType, force, takeControl } = parseBody(ResizeSchema, req.body); const session = findSessionOrFail(ctx, id, req); - session.resize(cols, rows, { viewportType, force }); + session.resize(cols, rows, { + viewportType, + force, + ...(takeControl === true ? { takeControl: true } : {}), + }); return {}; }); diff --git a/src/web/routes/ws-routes.ts b/src/web/routes/ws-routes.ts index 1b59d6eb..86af7714 100644 --- a/src/web/routes/ws-routes.ts +++ b/src/web/routes/ws-routes.ts @@ -26,7 +26,10 @@ * {"t":"i","d":"...","seq":N,"cid":"..."} — input (keystroke or paste). seq+cid are * optional reliable-delivery tags: the server applies each * (cid,seq) at-most-once and ACKs with {"t":"ia","seq":N}. - * {"t":"z","c":N,"r":N,"f":bool} — resize terminal (f=true forces SIGWINCH even if dims unchanged) + * Input also reasserts the connection's last announced viewport. + * {"t":"z","c":N,"r":N,"v":"mobile|tablet|desktop","f":bool,"a":bool} + * — resize terminal. f forces SIGWINCH; a explicitly takes + * shared PTY sizing control for this viewport. */ import { FastifyInstance } from 'fastify'; @@ -164,7 +167,9 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost // can ignore small-viewport resizes only while a desktop is actually // connected (see Session._desktopSizeClaims). const sizingToken = Symbol('ws-desktop-sizing'); - let holdsDesktopClaim = false; + let announcedViewport: 'mobile' | 'tablet' | 'desktop' | undefined; + let announcedCols = 0; + let announcedRows = 0; // Attach message handler synchronously BEFORE any async work // (@fastify/websocket requirement to avoid dropped messages). @@ -181,9 +186,15 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null; const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true; if (apply) { - // Typed input from a claim-holding desktop keeps the claim "hot" - // and re-asserts the desktop layout after a mobile override. - if (holdsDesktopClaim) session.noteDesktopActivity(); + // The most recently active viewport wins the shared PTY. Reuse + // this socket's announced dimensions so ordinary soft-keyboard + // input can reclaim mobile sizing without refitting the page. + if (announcedViewport && announcedCols > 0 && announcedRows > 0) { + session.resize(announcedCols, announcedRows, { + viewportType: announcedViewport, + takeControl: true, + }); + } session.write(msg.d); } if (seq !== null && socket.readyState === 1) { @@ -199,17 +210,22 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost msg.r <= 200 ) { const viewportType = msg.v === 'mobile' || msg.v === 'tablet' || msg.v === 'desktop' ? msg.v : undefined; + announcedViewport = viewportType; + announcedCols = msg.c; + announcedRows = msg.r; if (viewportType === 'desktop') { session.claimDesktopSizing(sizingToken); - holdsDesktopClaim = true; } else if (viewportType) { // The connection's viewport can change (e.g. browser window // narrowed past the tablet breakpoint) — drop a stale claim. session.releaseDesktopSizing(sizingToken); - holdsDesktopClaim = false; } const force = msg.f === true; - session.resize(msg.c, msg.r, { viewportType, force }); + session.resize(msg.c, msg.r, { + viewportType, + force, + ...(msg.a === true ? { takeControl: true } : {}), + }); } } catch { // Ignore malformed messages diff --git a/src/web/schemas.ts b/src/web/schemas.ts index a2034184..6c4ab8ef 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -261,6 +261,7 @@ export const ResizeSchema = z.object({ rows: z.number().int().min(1).max(200), viewportType: z.enum(['mobile', 'tablet', 'desktop']).optional(), force: z.boolean().optional(), + takeControl: z.boolean().optional(), }); /** diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index bda2077b..5e1a8b26 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -268,7 +268,6 @@ export class MockSession extends EventEmitter { /** Stubs for the desktop sizing claims used by resize arbitration */ claimDesktopSizing = vi.fn(); releaseDesktopSizing = vi.fn(); - noteDesktopActivity = vi.fn(); /** Stub for runPrompt */ runPrompt = vi.fn(async () => {}); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 259ed71d..bbe4e236 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -584,6 +584,20 @@ describe('session-routes', () => { expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: true }); }); + it('passes explicit viewport takeover through for HTTP fallback', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/resize`, + payload: { cols: 48, rows: 28, viewportType: 'mobile', takeControl: true }, + }); + expect(res.statusCode).toBe(200); + expect(harness.ctx._session.resize).toHaveBeenCalledWith(48, 28, { + viewportType: 'mobile', + force: undefined, + takeControl: true, + }); + }); + it('rejects cols exceeding max (500)', async () => { const res = await harness.app.inject({ method: 'POST', diff --git a/test/routes/ws-routes.test.ts b/test/routes/ws-routes.test.ts index 5b722036..27c0b7a3 100644 --- a/test/routes/ws-routes.test.ts +++ b/test/routes/ws-routes.test.ts @@ -208,6 +208,95 @@ describe('ws-routes', () => { } }); + it('reasserts the last mobile viewport before applying ordinary keyboard input', async () => { + const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); + try { + const session = ctx._session; + ws.send(JSON.stringify({ t: 'z', c: 48, r: 28, v: 'mobile' })); + await vi.waitFor(() => { + expect(session.resize).toHaveBeenCalledWith(48, 28, { + viewportType: 'mobile', + force: false, + }); + }); + session.resize.mockClear(); + + ws.send(JSON.stringify({ t: 'i', d: 'a' })); + await vi.waitFor(() => { + expect(session.writeBuffer).toContain('a'); + }); + + expect(session.resize).toHaveBeenCalledWith(48, 28, { + viewportType: 'mobile', + takeControl: true, + }); + } finally { + ws.close(); + } + }); + + it('reasserts the sending desktop connection dimensions as sizing activity', async () => { + const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); + try { + const session = ctx._session; + ws.send(JSON.stringify({ t: 'z', c: 180, r: 50, v: 'desktop' })); + await vi.waitFor(() => { + expect(session.claimDesktopSizing).toHaveBeenCalledOnce(); + }); + session.resize.mockClear(); + + ws.send(JSON.stringify({ t: 'i', d: 'a' })); + await vi.waitFor(() => { + expect(session.writeBuffer).toContain('a'); + }); + + expect(session.resize).toHaveBeenCalledWith(180, 50, { + viewportType: 'desktop', + takeControl: true, + }); + } finally { + ws.close(); + } + }); + + it('uses the active desktop socket dimensions when multiple desktops are connected', async () => { + const desktopA = await connectWs('/ws/sessions/ws-test-session/terminal?cid=desktop-a'); + const desktopB = await connectWs('/ws/sessions/ws-test-session/terminal?cid=desktop-b'); + try { + const session = ctx._session; + desktopA.send(JSON.stringify({ t: 'z', c: 160, r: 44, v: 'desktop' })); + desktopB.send(JSON.stringify({ t: 'z', c: 220, r: 56, v: 'desktop' })); + await vi.waitFor(() => { + expect(session.resize).toHaveBeenCalledWith(160, 44, { + viewportType: 'desktop', + force: false, + }); + expect(session.resize).toHaveBeenCalledWith(220, 56, { + viewportType: 'desktop', + force: false, + }); + }); + session.resize.mockClear(); + + desktopA.send(JSON.stringify({ t: 'i', d: 'from-a' })); + await vi.waitFor(() => { + expect(session.writeBuffer).toContain('from-a'); + }); + + expect(session.resize).toHaveBeenCalledWith(160, 44, { + viewportType: 'desktop', + takeControl: true, + }); + expect(session.resize).not.toHaveBeenCalledWith(220, 56, { + viewportType: 'desktop', + takeControl: true, + }); + } finally { + desktopA.close(); + desktopB.close(); + } + }); + it('ignores input exceeding MAX_INPUT_LENGTH', async () => { const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); try { @@ -322,6 +411,25 @@ describe('ws-routes', () => { } }); + it('passes explicit viewport takeover through for active mobile interaction', async () => { + const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); + try { + const session = ctx._session; + + ws.send(JSON.stringify({ t: 'z', c: 48, r: 28, v: 'mobile', a: true })); + + await vi.waitFor(() => { + expect(session.resize).toHaveBeenCalledWith(48, 28, { + viewportType: 'mobile', + force: false, + takeControl: true, + }); + }); + } finally { + ws.close(); + } + }); + it('claims desktop sizing on a desktop resize and releases it on close', async () => { const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); const session = ctx._session; diff --git a/test/session-resize-arbitration.test.ts b/test/session-resize-arbitration.test.ts index 5f82e527..87944463 100644 --- a/test/session-resize-arbitration.test.ts +++ b/test/session-resize-arbitration.test.ts @@ -19,6 +19,11 @@ function attachFakePty(session: Session, cols = 160, rows = 48) { return resize; } +function registerActiveDesktop(session: Session, token: symbol, cols = 160, rows = 48): void { + session.claimDesktopSizing(token); + session.resize(cols, rows, { viewportType: 'desktop', takeControl: true }); +} + describe('Session resize arbitration', () => { it('lets a mobile-only session shrink below the spawn default (no desktop connected)', () => { const session = new Session({ workingDir: '/tmp', mode: 'shell' }); @@ -54,7 +59,7 @@ describe('Session resize arbitration', () => { const resize = attachFakePty(session, 160, 48); const desktop = Symbol('desktop-conn'); - session.claimDesktopSizing(desktop); + registerActiveDesktop(session, desktop); session.resize(48, 28, { viewportType: 'mobile' }); // Grow is ignored too — it would reflow the desktop view just the same. session.resize(200, 60, { viewportType: 'tablet' }); @@ -62,7 +67,22 @@ describe('Session resize arbitration', () => { expect(resize).not.toHaveBeenCalled(); }); - it('always applies desktop resizes, claim or not', () => { + it('lets an explicitly active mobile viewport take control of a fresh desktop claim', () => { + const session = new Session({ workingDir: '/tmp', mode: 'shell' }); + const resize = attachFakePty(session, 160, 48); + const desktop = Symbol('desktop-conn'); + + registerActiveDesktop(session, desktop, 208, 45); + resize.mockClear(); + + session.resize(48, 28, { viewportType: 'mobile', takeControl: true }); + expect(resize).toHaveBeenCalledWith(48, 28); + + session.resize(208, 45, { viewportType: 'desktop', takeControl: true }); + expect(resize).toHaveBeenLastCalledWith(208, 45); + }); + + it('applies passive desktop resizes while no mobile override is active', () => { const session = new Session({ workingDir: '/tmp', mode: 'shell' }); const resize = attachFakePty(session, 160, 48); @@ -72,12 +92,45 @@ describe('Session resize arbitration', () => { expect(resize).toHaveBeenCalledWith(120, 40); }); - it('restores mobile control once the desktop claim is released', () => { + it('ignores a passive desktop restore while a mobile viewport is active', () => { + const session = new Session({ workingDir: '/tmp', mode: 'shell' }); + const resize = attachFakePty(session, 160, 48); + const desktop = Symbol('desktop-conn'); + + registerActiveDesktop(session, desktop, 208, 45); + resize.mockClear(); + + session.resize(48, 28, { viewportType: 'mobile', takeControl: true }); + session.resize(220, 50, { viewportType: 'desktop' }); + expect(resize).toHaveBeenCalledTimes(1); + expect(resize).toHaveBeenLastCalledWith(48, 28); + + session.resize(220, 50, { viewportType: 'desktop', takeControl: true }); + expect(resize).toHaveBeenLastCalledWith(220, 50); + }); + + it('preserves an explicit mobile takeover when a desktop connects afterward', () => { const session = new Session({ workingDir: '/tmp', mode: 'shell' }); const resize = attachFakePty(session, 160, 48); const desktop = Symbol('desktop-conn'); + session.resize(48, 28, { viewportType: 'mobile', takeControl: true }); session.claimDesktopSizing(desktop); + session.resize(208, 45, { viewportType: 'desktop' }); + + expect(resize).toHaveBeenCalledTimes(1); + expect(resize).toHaveBeenLastCalledWith(48, 28); + + session.resize(208, 45, { viewportType: 'desktop', takeControl: true }); + expect(resize).toHaveBeenLastCalledWith(208, 45); + }); + + it('restores mobile control once the desktop claim is released', () => { + const session = new Session({ workingDir: '/tmp', mode: 'shell' }); + const resize = attachFakePty(session, 160, 48); + const desktop = Symbol('desktop-conn'); + + registerActiveDesktop(session, desktop); session.resize(48, 28, { viewportType: 'mobile' }); expect(resize).not.toHaveBeenCalled(); @@ -94,6 +147,7 @@ describe('Session resize arbitration', () => { session.claimDesktopSizing(desktopA); session.claimDesktopSizing(desktopB); + session.resize(160, 48, { viewportType: 'desktop', takeControl: true }); session.releaseDesktopSizing(desktopA); session.resize(48, 28, { viewportType: 'mobile' }); expect(resize).not.toHaveBeenCalled(); @@ -132,7 +186,7 @@ describe('Session resize arbitration', () => { const session = new Session({ workingDir: '/tmp', mode: 'shell' }); const resize = attachFakePty(session, 160, 48); - session.claimDesktopSizing(Symbol('desktop-conn')); + registerActiveDesktop(session, Symbol('desktop-conn')); session.resize(48, 28, { viewportType: 'mobile' }); expect(resize).not.toHaveBeenCalled(); // fresh claim → ignored @@ -146,9 +200,9 @@ describe('Session resize arbitration', () => { const session = new Session({ workingDir: '/tmp', mode: 'shell' }); const resize = attachFakePty(session, 160, 48); - session.claimDesktopSizing(Symbol('desktop-conn')); + registerActiveDesktop(session, Symbol('desktop-conn')); vi.advanceTimersByTime(PAST_IDLE_MS - 10_000); - session.noteDesktopActivity(); // user typed on desktop + session.resize(160, 48, { viewportType: 'desktop', takeControl: true }); vi.advanceTimersByTime(20_000); // idle since claim, but not since input session.resize(48, 28, { viewportType: 'mobile' }); @@ -160,14 +214,13 @@ describe('Session resize arbitration', () => { const session = new Session({ workingDir: '/tmp', mode: 'shell' }); const resize = attachFakePty(session, 160, 48); - session.resize(208, 45, { viewportType: 'desktop' }); // desktop sizes the pane - session.claimDesktopSizing(Symbol('desktop-conn')); + registerActiveDesktop(session, Symbol('desktop-conn'), 208, 45); vi.advanceTimersByTime(PAST_IDLE_MS); session.resize(48, 28, { viewportType: 'mobile' }); // phone takes over expect(resize).toHaveBeenLastCalledWith(48, 28); - session.noteDesktopActivity(); // desktop user types again + session.resize(208, 45, { viewportType: 'desktop', takeControl: true }); expect(resize).toHaveBeenLastCalledWith(208, 45); // layout restored }); @@ -175,9 +228,9 @@ describe('Session resize arbitration', () => { const session = new Session({ workingDir: '/tmp', mode: 'shell' }); const resize = attachFakePty(session, 160, 48); - session.resize(208, 45, { viewportType: 'desktop' }); + session.resize(208, 45, { viewportType: 'desktop', takeControl: true }); resize.mockClear(); - session.noteDesktopActivity(); + session.resize(208, 45, { viewportType: 'desktop', takeControl: true }); expect(resize).not.toHaveBeenCalled(); }); }); From 4c12e826bf57c71a7a22e9f8ceec025e7da456c5 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 29 Jul 2026 11:47:01 +0300 Subject: [PATCH 2/2] fix(terminal): let active viewport reclaim PTY sizing --- config/vitest.ci.config.ts | 1 + .../phase7-test-infrastructure-plan.md | 2 +- src/web/public/app.js | 30 ++- src/web/public/mobile-handlers.js | 23 +- src/web/public/terminal-ui.js | 111 ++++++---- test/mobile/keyboard.test.ts | 24 +++ test/terminal-touch-tap.test.ts | 112 +++++++++- test/terminal-viewport-ownership.test.ts | 197 ++++++++++++++++++ 8 files changed, 441 insertions(+), 59 deletions(-) create mode 100644 test/terminal-viewport-ownership.test.ts diff --git a/config/vitest.ci.config.ts b/config/vitest.ci.config.ts index 24e06052..f5f1006c 100644 --- a/config/vitest.ci.config.ts +++ b/config/vitest.ci.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ 'test/perf-*.test.ts', // timing-sensitive perf benchmarks (flaky in CI) 'test/inline-rename.test.ts', // browser (Playwright) 'test/opencode-resize.test.ts', // browser (Playwright) + 'test/terminal-viewport-ownership.test.ts', // browser (Playwright) 'test/webgl-fallback.test.ts', // browser (Playwright) ], setupFiles: ['./test/setup.ts'], diff --git a/docs/archive/phase7-test-infrastructure-plan.md b/docs/archive/phase7-test-infrastructure-plan.md index 0023c081..4d72aed1 100644 --- a/docs/archive/phase7-test-infrastructure-plan.md +++ b/docs/archive/phase7-test-infrastructure-plan.md @@ -120,7 +120,7 @@ This avoids port conflicts entirely and runs fast. Only tests that need SSE or W | 3223 | `test/routes/ralph-routes.test.ts` | Ralph API | | 3224–3229 | Reserved | Future route tests | -Most tests should NOT need real ports — `app.inject()` is preferred. Verified: ports 3220–3229 are completely unused by existing tests (highest used port is 3211 in `opencode-resize.test.ts`). +Most tests should NOT need real ports — `app.inject()` is preferred. Verified: ports 3220–3229 are completely unused by existing tests (highest used port is 3213 in `terminal-viewport-ownership.test.ts`). --- diff --git a/src/web/public/app.js b/src/web/public/app.js index 6f953be8..6c5c71ac 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1316,7 +1316,7 @@ class CodemanApp { if (!session) { this._showSoloSessionGone(); return; } // Force re-select (handleInit cleared terminal state above). this.activeSessionId = null; - this.selectSession(this.soloSessionId); + this.selectSession(this.soloSessionId, { takeControl: false }); const name = this.getSessionName(session) || 'Session'; const titleEl = document.getElementById('soloSessionTitle'); if (titleEl) { titleEl.textContent = name; titleEl.style.display = ''; } @@ -3184,9 +3184,9 @@ class CodemanApp { try { restoreId = localStorage.getItem('codeman-active-session'); } catch {} } if (restoreId && this.sessions.has(restoreId)) { - this.selectSession(restoreId); + this.selectSession(restoreId, { takeControl: false }); } else { - this.selectSession(this.sessionOrder[0]); + this.selectSession(this.sessionOrder[0], { takeControl: false }); } } } @@ -3621,6 +3621,11 @@ class CodemanApp { handleSessionTabClick(event, sessionId) { event?.preventDefault?.(); + // Desktop activation must clear any mobile inline layout left by a prior + // emulated/touch viewport before measuring the terminal container. + if (typeof KeyboardHandler !== 'undefined') { + KeyboardHandler.resetForDesktopViewport?.(); + } // On touch with the keyboard hidden, blur the tapped tab so switching // sessions doesn't pop the on-screen keyboard. Focus policy itself lives // in selectSession via _shouldFocusTerminalForTabSwitch(). @@ -4043,6 +4048,11 @@ class CodemanApp { return typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible; } + /** + * Select and render a session. + * Automatic restore/reconnect callers must pass takeControl:false so a + * background viewport cannot bypass server-side resize arbitration. + */ async selectSession(sessionId, options = {}) { // If this session is popped out into its own window, raise that window // instead of showing it inline (focus-on-click for detached tabs). If we @@ -4052,6 +4062,7 @@ class CodemanApp { if (this._raiseDetached(sessionId)) return; } const forceReload = options?.forceReload === true; + const takeControl = options?.takeControl !== false; if (this.activeSessionId === sessionId && !forceReload) return; if (this.activeSessionId === sessionId && forceReload) { this.terminalBufferCache?.delete(sessionId); @@ -4204,7 +4215,16 @@ class CodemanApp { // a small region with empty rows below the status bar. // sendResize is a no-op on the server when dims haven't changed, so // calling it every tab switch is cheap. - const dimsChanged = await this.sendResize(sessionId, { forceHttp: true }).catch(() => false); + const forceDesktopResize = + forceReload && + (typeof MobileDetection === 'undefined' || + !MobileDetection.getDeviceType || + MobileDetection.getDeviceType() === 'desktop'); + const dimsChanged = await this.sendResize(sessionId, { + force: forceDesktopResize, + refit: false, + ...(takeControl ? { takeControl: true } : {}), + }).catch(() => false); if (this._isStaleSelect(selectGen)) { this._clearTerminalLoadState(sessionId, selectGen); return; @@ -4395,7 +4415,7 @@ class CodemanApp { // conversation. Stale Ink frames in the tailed buffer are a cosmetic // annoyance that disappear on the user's next keypress; data loss is not // acceptable. Do NOT re-introduce Ctrl+L here. - this.sendResize(sessionId); + this.sendResize(sessionId, takeControl ? { takeControl: true } : {}); // Defer secondary panel updates so they don't block the main thread // after terminal content is already visible. diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index 5b8b99a5..075d0e15 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -403,6 +403,14 @@ const KeyboardHandler = { } }, + /** Clear stale mobile keyboard state before a desktop viewport measures xterm. */ + resetForDesktopViewport() { + if (typeof MobileDetection === 'undefined' || MobileDetection.getDeviceType?.() !== 'desktop') return; + this.keyboardVisible = false; + document.body.classList.remove('keyboard-visible'); + this.resetLayout(); + }, + /** Called when keyboard appears */ onKeyboardShow() { // Show keyboard accessory bar @@ -474,20 +482,7 @@ const KeyboardHandler = { _sendTerminalResize() { if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return; try { - const dims = app.fitAddon.proposeDimensions(); - if (dims) { - const cols = Math.max(dims.cols, 40); - const rows = Math.max(dims.rows, 10); - app._lastResizeDims = { cols, rows }; - // Declare the viewport type so resize arbitration can ignore this - // while a desktop connection is sizing the same session. - const viewportType = MobileDetection.getDeviceType ? MobileDetection.getDeviceType() : 'mobile'; - fetch(`/api/sessions/${app.activeSessionId}/resize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cols, rows, viewportType }), - }).catch(() => {}); - } + app.sendResize(app.activeSessionId, { takeControl: true, refit: false }).catch(() => {}); } catch {} }, diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 3a9de819..4a971d2e 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -570,6 +570,19 @@ Object.assign(CodemanApp.prototype, { // Hand-encode the SGR report for plain left-clicks on those sessions. container.addEventListener('click', (ev) => this._handleDesktopTerminalClick(ev)); + // The PTY has one shared size across all connected viewports. Let the page + // receiving a real interaction claim it at that viewport's dimensions. + if (!this._terminalSizingPointerHandler) { + this._terminalSizingPointerHandler = (ev) => this._handleTerminalSizingPointerDown(ev); + document.addEventListener('pointerdown', this._terminalSizingPointerHandler, true); + this._terminalSizingFocusHandler = () => this._scheduleTerminalSizingClaim(); + this._terminalSizingVisibilityHandler = () => { + if (document.visibilityState === 'visible') this._scheduleTerminalSizingClaim(); + }; + window.addEventListener('focus', this._terminalSizingFocusHandler); + document.addEventListener('visibilitychange', this._terminalSizingVisibilityHandler); + } + // Welcome message this.showWelcome(); @@ -586,10 +599,6 @@ Object.assign(CodemanApp.prototype, { this._resizeTimeout = null; this._lastResizeDims = null; - // Minimum terminal dimensions to prevent vertical text wrapping - const MIN_COLS = 40; - const MIN_ROWS = 10; - const throttledResize = () => { // Trailing-edge debounce: ALL resize work (fit + clear + SIGWINCH) happens // once after the user stops resizing. During active resize, the terminal @@ -625,12 +634,14 @@ Object.assign(CodemanApp.prototype, { // Local fit() still runs so xterm knows the viewport size for scrolling. const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible; if (this.activeSessionId && !keyboardUp) { - const dims = this.fitAddon.proposeDimensions(); - // Enforce minimum dimensions to prevent layout issues - const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS; - const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS; + const dims = this.getTerminalDimensions(); // Only send resize if dimensions actually changed - if (!this._lastResizeDims || cols !== this._lastResizeDims.cols || rows !== this._lastResizeDims.rows) { + if ( + dims && + (!this._lastResizeDims || + dims.cols !== this._lastResizeDims.cols || + dims.rows !== this._lastResizeDims.rows) + ) { // Clear viewport + scrollback ONLY when dimensions actually change. // fitAddon.fit() reflows content: lines at old width may wrap to more rows, // pushing overflow into scrollback. Ink's cursor-up count is based on the @@ -648,31 +659,9 @@ Object.assign(CodemanApp.prototype, { ) { this.terminal.write('\x1b[3J\x1b[H\x1b[2J'); } - this._lastResizeDims = { cols, rows }; - // Typed + WS-first like sendResize: the viewport type feeds resize - // arbitration (a phone rotating must not bypass a desktop claim), - // and a desktop window narrowing past the tablet breakpoint must - // send a typed WS frame so its stale desktop claim is released. - const viewportType = - typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType - ? MobileDetection.getDeviceType() - : 'desktop'; - let sentViaWs = false; - if (this._wsReady && this._wsSessionId === this.activeSessionId) { - try { - this._ws.send(JSON.stringify({ t: 'z', c: cols, r: rows, v: viewportType })); - sentViaWs = true; - } catch { - // Fall through to HTTP POST - } - } - if (!sentViaWs) { - fetch(`/api/sessions/${this.activeSessionId}/resize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cols, rows, viewportType }), - }).catch(() => {}); - } + // sendResize owns dimension tracking, viewport classification, and + // the WebSocket-first transport with HTTP fallback. + this.sendResize(this.activeSessionId, { refit: false }).catch(() => {}); } } // Update subagent connection lines and local echo at new dimensions @@ -2572,7 +2561,7 @@ Object.assign(CodemanApp.prototype, { // Force resize even when dimensions match the server's last known state — // another device may have changed the PTY size since this client last sent, // and force guarantees a SIGWINCH → Ink redraw at the current device's size. - await this.sendResize(this.activeSessionId, { force: true }); + await this.sendResize(this.activeSessionId, { force: true, takeControl: true }); this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success'); } catch (err) { @@ -2815,6 +2804,42 @@ Object.assign(CodemanApp.prototype, { this._sendSyntheticSgrTap(ev.clientX, ev.clientY); }, + _handleTerminalSizingPointerDown(ev) { + if (!ev?.isTrusted || ev.button !== 0 || ev.isPrimary === false) return; + const target = ev.target?.closest?.('#terminalContainer, .session-tab, .keyboard-accessory-bar'); + if (!target) return; + const isDesktopViewport = + typeof MobileDetection !== 'undefined' && + MobileDetection.getDeviceType?.() === 'desktop'; + const forceRedraw = + isDesktopViewport && + Boolean(target.matches?.('#terminalContainer, .session-tab')); + this._scheduleTerminalSizingClaim({ force: forceRedraw }); + }, + + _scheduleTerminalSizingClaim(options = {}) { + if (!this.activeSessionId || !this.fitAddon) return; + // Multiple focus/pointer signals can land in the same animation frame. + // Preserve the strongest request so an earlier passive focus claim cannot + // swallow a later explicit desktop terminal takeover. + if (options.force) this._terminalSizingClaimForce = true; + if (this._terminalSizingClaimFrame) return; + + this._terminalSizingClaimFrame = requestAnimationFrame(() => { + this._terminalSizingClaimFrame = null; + const force = this._terminalSizingClaimForce === true; + this._terminalSizingClaimForce = false; + if (document.visibilityState === 'hidden' || !this.activeSessionId || !this.fitAddon) return; + const keyboardVisible = + typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible; + this.sendResize(this.activeSessionId, { + ...(force ? { force: true } : {}), + takeControl: true, + refit: !keyboardVisible, + }).catch(() => {}); + }); + }, + _installMobileTapMouseGuard() { const el = this.terminal?.element; if (!el || el._codemanTapMouseGuardInstalled) return; @@ -2884,14 +2909,22 @@ Object.assign(CodemanApp.prototype, { /** * Send resize to a session with minimum dimension enforcement. * @param {string} sessionId - * @param {{ forceHttp?: boolean, force?: boolean }} [options] + * @param {{ forceHttp?: boolean, force?: boolean, takeControl?: boolean, refit?: boolean }} [options] * @returns {Promise} Whether dimensions changed from the last send */ async sendResize(sessionId, options = {}) { // 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(); - const dims = this.getTerminalDimensions(); + if (options.refit !== false && this.fitAddon) this.fitAddon.fit(); + const dims = + options.refit === false && + Number.isInteger(this.terminal?.cols) && + Number.isInteger(this.terminal?.rows) + ? { + cols: Math.max(this.terminal.cols, 40), + rows: Math.max(this.terminal.rows, 10), + } + : this.getTerminalDimensions(); if (!dims) return false; // Did the dimensions actually change since the last resize we sent? Callers // use this to skip work (e.g. the post-resize TUI-redraw settle) when no @@ -2916,6 +2949,7 @@ Object.assign(CodemanApp.prototype, { try { const msg = { t: 'z', c: dims.cols, r: dims.rows, v: viewportType }; if (options.force) msg.f = true; + if (options.takeControl) msg.a = true; this._ws.send(JSON.stringify(msg)); return changed; } catch { @@ -2924,6 +2958,7 @@ Object.assign(CodemanApp.prototype, { } const body = { ...dims, viewportType }; if (options.force) body.force = true; + if (options.takeControl) body.takeControl = true; await fetch(`/api/sessions/${sessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index 5032f653..b7803b15 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -276,6 +276,30 @@ describe('Virtual Keyboard', () => { expect(newPx).toBeGreaterThan(initialPx); }); + it('marks keyboard-driven resizing as active viewport control', async () => { + const call = await page.evaluate(`(async function() { + var originalSendResize = app.sendResize; + var captured = null; + app.activeSessionId = 'mobile-keyboard-takeover'; + app.sendResize = function(sessionId, options) { + captured = { sessionId: sessionId, options: options }; + return Promise.resolve(false); + }; + try { + KeyboardHandler._sendTerminalResize(); + await Promise.resolve(); + return captured; + } finally { + app.sendResize = originalSendResize; + } + })()`); + + expect(call).toEqual({ + sessionId: 'mobile-keyboard-takeover', + options: { takeControl: true, refit: false }, + }); + }); + it('does not reserve the keyboard height as visible terminal dead space', async () => { await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT); await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION); diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 7c4ad557..a0dc1358 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -6,13 +6,24 @@ import { describe, expect, it, vi } from 'vitest'; function loadTerminalUiHarness() { const CodemanApp = function CodemanApp(this: any) {}; let now = 1_000; + let frameCallback: (() => void) | null = null; + let keyboardVisible = false; + let deviceType = 'desktop'; const context = vm.createContext({ window: {}, + document: { + visibilityState: 'visible', + documentElement: { dataset: {} }, + body: { classList: { contains: () => false } }, + }, CodemanApp, console: { warn: vi.fn(), log: vi.fn() }, _crashDiag: { log: vi.fn() }, performance: { now: () => now }, - requestAnimationFrame: (_fn: () => void) => 1, + requestAnimationFrame: (fn: () => void) => { + frameCallback = fn; + return 1; + }, setTimeout: (_fn: () => void) => 1, Blob: function Blob() {}, URL: { @@ -24,6 +35,12 @@ function loadTerminalUiHarness() { }, MobileDetection: { isTouchDevice: () => true, + getDeviceType: () => deviceType, + }, + KeyboardHandler: { + get keyboardVisible() { + return keyboardVisible; + }, }, DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, TERMINAL_CHUNK_SIZE: 32 * 1024, @@ -38,6 +55,17 @@ function loadTerminalUiHarness() { setNow: (value: number) => { now = value; }, + runFrame: () => { + const callback = frameCallback; + frameCallback = null; + callback?.(); + }, + setKeyboardVisible: (visible: boolean) => { + keyboardVisible = visible; + }, + setDeviceType: (type: string) => { + deviceType = type; + }, }; } @@ -458,3 +486,85 @@ describe('terminal touch tap mouse guard', () => { expect(event.stopImmediatePropagation).not.toHaveBeenCalled(); }); }); + +describe('terminal viewport sizing claims', () => { + it('forces one redraw for a trusted desktop terminal pointer and ignores unrelated targets', async () => { + const { app, runFrame } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.fitAddon = {}; + app.sendResize = vi.fn(() => Promise.resolve(true)); + const terminalTarget = { matches: () => true }; + + app._handleTerminalSizingPointerDown({ + isTrusted: true, + button: 0, + isPrimary: true, + target: { closest: () => terminalTarget }, + }); + runFrame(); + expect(app.sendResize).toHaveBeenCalledOnce(); + expect(app.sendResize).toHaveBeenCalledWith('sess-1', { + force: true, + takeControl: true, + refit: true, + }); + + app.sendResize.mockClear(); + app._handleTerminalSizingPointerDown({ + isTrusted: true, + button: 0, + isPrimary: true, + target: { closest: () => null }, + }); + runFrame(); + expect(app.sendResize).not.toHaveBeenCalled(); + }); + + it('upgrades an already queued passive claim when a terminal click arrives', () => { + const { app, runFrame } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.fitAddon = {}; + app.sendResize = vi.fn(() => Promise.resolve(true)); + + app._scheduleTerminalSizingClaim(); + const terminalTarget = { matches: () => true }; + app._handleTerminalSizingPointerDown({ + isTrusted: true, + button: 0, + isPrimary: true, + target: { closest: () => terminalTarget }, + }); + runFrame(); + + expect(app.sendResize).toHaveBeenCalledOnce(); + expect(app.sendResize).toHaveBeenCalledWith('sess-1', { + force: true, + takeControl: true, + refit: true, + }); + }); + + it('claims sizing from a keyboard-open accessory tap without refitting', () => { + const { app, runFrame, setKeyboardVisible, setDeviceType } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.fitAddon = {}; + app.sendResize = vi.fn(() => Promise.resolve(true)); + setDeviceType('mobile'); + setKeyboardVisible(true); + const accessoryTarget = { matches: () => false }; + + app._handleTerminalSizingPointerDown({ + isTrusted: true, + button: 0, + isPrimary: true, + target: { closest: () => accessoryTarget }, + }); + runFrame(); + + expect(app.sendResize).toHaveBeenCalledOnce(); + expect(app.sendResize).toHaveBeenCalledWith('sess-1', { + takeControl: true, + refit: false, + }); + }); +}); diff --git a/test/terminal-viewport-ownership.test.ts b/test/terminal-viewport-ownership.test.ts new file mode 100644 index 00000000..3422afce --- /dev/null +++ b/test/terminal-viewport-ownership.test.ts @@ -0,0 +1,197 @@ +/** + * @fileoverview Browser coverage for shared terminal viewport ownership. + * + * Port: 3213 + */ + +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'; +import type { WebServer } from '../src/web/server.js'; + +const PORT = 3213; +const BASE_URL = `http://localhost:${PORT}`; + +let server: WebServer; +let browser: Browser; +let context: BrowserContext | undefined; +let dataDir: string; +let previousDataDir: string | undefined; + +async function freshPage(): Promise { + context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + return context.newPage(); +} + +async function navigateAndWait(page: Page): Promise { + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { + timeout: 5000, + }); +} + +async function createSession(page: Page, name: string): Promise { + const created = await page.evaluate(async (sessionName: string) => { + const response = await fetch('/api/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workingDir: '/tmp', name: sessionName }), + }); + const body = await response.json(); + return { + id: body.data?.session?.id ?? body.data?.id ?? body.session?.id ?? body.id, + body, + }; + }, name); + expect(created.id, JSON.stringify(created.body)).toBeTruthy(); + return created.id; +} + +async function deleteSession(page: Page, sessionId: string): Promise { + await page.evaluate(async (id: string) => { + await fetch(`/api/sessions/${id}`, { method: 'DELETE' }); + }, sessionId); +} + +async function selectSession(page: Page, sessionId: string, forceReload = false): Promise { + await page.evaluate( + async ({ id, force }: { id: string; force: boolean }) => { + const app = ( + window as unknown as { + app: { selectSession: (target: string, options?: { forceReload: boolean }) => Promise }; + } + ).app; + await app.selectSession(id, force ? { forceReload: true } : undefined); + }, + { id: sessionId, force: forceReload } + ); +} + +async function waitForSessionSocket(page: Page, sessionId: string): Promise { + await page.waitForFunction((id: string) => { + const app = ( + window as unknown as { + app: { _wsReady: boolean; _wsSessionId: string | null }; + } + ).app; + return app._wsReady && app._wsSessionId === id; + }, sessionId); +} + +beforeAll(async () => { + previousDataDir = process.env.CODEMAN_DATA_DIR; + dataDir = await mkdtemp(join(tmpdir(), 'codeman-viewport-test-')); + process.env.CODEMAN_DATA_DIR = dataDir; + const { WebServer } = await import('../src/web/server.js'); + server = new WebServer(PORT, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); +}, 30_000); + +afterEach(async () => { + await context?.close(); + context = undefined; +}); + +afterAll(async () => { + try { + await browser?.close(); + await server?.stop(); + } finally { + if (previousDataDir === undefined) delete process.env.CODEMAN_DATA_DIR; + else process.env.CODEMAN_DATA_DIR = previousDataDir; + if (dataDir) await rm(dataDir, { recursive: true, force: true }); + } +}, 30_000); + +describe('terminal viewport ownership', () => { + it('forces the full desktop PTY size when a session tab is reactivated', async () => { + const page = await freshPage(); + await navigateAndWait(page); + const resizeCalls: Array<{ viewportType?: string; force?: boolean; takeControl?: boolean }> = []; + await page.route('**/api/sessions/*/resize', async (route) => { + resizeCalls.push(route.request().postDataJSON()); + await route.continue(); + }); + const sessionId = await createSession(page, 'desktop-reactivation-test'); + + try { + await selectSession(page, sessionId, true); + await expect + .poll(() => + resizeCalls.some( + (call) => call.viewportType === 'desktop' && call.force === true && call.takeControl === true + ) + ) + .toBe(true); + } finally { + await deleteSession(page, sessionId); + } + }); + + it('reclaims desktop sizing over the live WebSocket on a terminal click', async () => { + const page = await freshPage(); + const resizeFrames: Array> = []; + page.on('websocket', (socket) => { + socket.on('framesent', (event) => { + try { + const frame = JSON.parse(String(event.payload)) as Record; + if (frame.t === 'z') resizeFrames.push(frame); + } catch { + // Ignore non-JSON frames. + } + }); + }); + await navigateAndWait(page); + const sessionId = await createSession(page, 'desktop-click-resize-test'); + + try { + await selectSession(page, sessionId); + await waitForSessionSocket(page, sessionId); + resizeFrames.length = 0; + await page.locator('#terminalContainer').click({ position: { x: 40, y: 40 } }); + await vi.waitFor(() => { + expect(resizeFrames).toEqual( + expect.arrayContaining([expect.objectContaining({ t: 'z', v: 'desktop', a: true, f: true })]) + ); + }); + } finally { + await deleteSession(page, sessionId); + } + }); + + it('reclaims desktop sizing when the page regains focus', async () => { + const page = await freshPage(); + const resizeFrames: Array> = []; + page.on('websocket', (socket) => { + socket.on('framesent', (event) => { + try { + const frame = JSON.parse(String(event.payload)) as Record; + if (frame.t === 'z') resizeFrames.push(frame); + } catch { + // Ignore non-JSON frames. + } + }); + }); + await navigateAndWait(page); + const sessionId = await createSession(page, 'desktop-focus-resize-test'); + + try { + await selectSession(page, sessionId); + await waitForSessionSocket(page, sessionId); + resizeFrames.length = 0; + await page.evaluate(() => window.dispatchEvent(new FocusEvent('focus'))); + await vi.waitFor(() => { + expect(resizeFrames).toEqual( + expect.arrayContaining([expect.objectContaining({ t: 'z', v: 'desktop', a: true })]) + ); + }); + const focusFrame = resizeFrames.find((frame) => frame.t === 'z' && frame.a === true); + expect(focusFrame).not.toHaveProperty('f'); + } finally { + await deleteSession(page, sessionId); + } + }); +});