diff --git a/public/main.ts b/public/main.ts index 215f0a5..195f831 100644 --- a/public/main.ts +++ b/public/main.ts @@ -45,6 +45,14 @@ app.applySettings(settings) // Key bar (mobile + desktop) sends to whichever tab is active. mountKeybar((data) => app.sendToActive(data)) +// Multi-device: when this device regains focus, re-assert the active tab's size +// so a shared session snaps to THIS screen (latest-writer-wins) — full-screen on +// whichever device you're currently using. +window.addEventListener('focus', () => app.refitActive()) +document.addEventListener('visibilitychange', () => { + if (!document.hidden) app.refitActive() +}) + // Toolbar utilities. mountSearch(toolbar, { find: (query, dir) => app.findInActive(query, dir), diff --git a/public/tabs.ts b/public/tabs.ts index a6b81f7..cd3eaba 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -337,6 +337,12 @@ export class TabApp { this.tabs[this.activeIndex]?.session.send(data) } + /** Re-assert the active tab's size (latest-writer-wins) when this device + * regains focus, so a shared session snaps back to this screen's size. */ + refitActive(): void { + this.tabs[this.activeIndex]?.session.refit() + } + /** Apply theme + font settings to every terminal (M3). */ applySettings(s: Settings): void { this.settings = s diff --git a/public/terminal-session.ts b/public/terminal-session.ts index 99638f0..da8c419 100644 --- a/public/terminal-session.ts +++ b/public/terminal-session.ts @@ -349,6 +349,19 @@ export class TerminalSession { }) } + /** + * Re-fit and FORCE-resend dims (latest-writer-wins): when this device regains + * focus, re-assert its size so the shared PTY snaps to this screen even if + * another device had resized it. No-op while hidden. + */ + refit(): void { + if (this.el.style.display === 'none') return + this.lastCols = -1 // force sendResize to fire even if our dims are unchanged + this.lastRows = -1 + const dims = this.safefit() + if (dims !== null) this.sendResize(dims.cols, dims.rows) + } + hide(): void { this.el.style.display = 'none' // v0.4: withdraw our size vote so a backgrounded mirror doesn't clamp the diff --git a/src/session/session.ts b/src/session/session.ts index 98e5b7c..2c94778 100644 --- a/src/session/session.ts +++ b/src/session/session.ts @@ -52,21 +52,9 @@ export function broadcast(session: Session, msg: ServerMessage): void { for (const ws of session.clients) sendIfOpen(ws, msg); } -/** - * Resize the PTY to the MIN cols/rows across all attached clients (tmux-style), - * so a small phone and a wide laptop sharing the session both see content - * without overflow. No-op after exit (L4), when no client is attached, or when - * the computed dims are unchanged (idempotent). - */ -function applyMinDims(session: Session): void { - if (session.exitedAt !== null || session.clientDims.size === 0) return; - let cols = Infinity; - let rows = Infinity; - for (const d of session.clientDims.values()) { - cols = Math.min(cols, d.cols); - rows = Math.min(rows, d.rows); - } - if (!Number.isFinite(cols) || !Number.isFinite(rows)) return; +/** Resize the PTY if the dims actually changed and it's still alive (L4). */ +function applyDims(session: Session, cols: number, rows: number): void { + if (session.exitedAt !== null) return; if (session.pty.cols === cols && session.pty.rows === rows) return; session.pty.resize(cols, rows); } @@ -154,10 +142,9 @@ export function createSession( * replay the scrollback to THIS client only so it sees the current screen. * No kicking — devices share the session (invariant #5 relaxed for v0.4). * - * The client does NOT get a size vote here: only a client that is actively - * viewing the session sends a `resize` (the frontend skips hidden panes), so a - * background mirror never clamps the shared PTY. The vote is added on the first - * `setClientDims` and removed on `clearClientDims`/`detachWs`. + * Attaching does NOT change the PTY size — the device actively using the + * session keeps its size. A client only sets the size once it sends a `resize` + * (the frontend does so when its pane is visible / regains focus). */ export function attachWs(session: Session, ws: WebSocketLike): void { session.clients.add(ws); @@ -175,10 +162,10 @@ export function attachWs(session: Session, ws: WebSocketLike): void { export function detachWs(session: Session, ws: WebSocketLike, now: number): void { session.clients.delete(ws); session.clientDims.delete(ws); + // A client leaving does NOT resize the PTY — whatever device is still actively + // viewing keeps its size. Start the idle clock only when the last client goes. if (session.clients.size === 0) { session.detachedAt = now; - } else { - applyMinDims(session); } } @@ -192,6 +179,13 @@ export function writeInput(session: Session, data: string): void { * Record one client's requested dims and resize the PTY to the new min across * all clients (tmux-style). No-op after exit (L4); idempotent when unchanged. */ +/** + * Latest-writer-wins: the device that most recently fit/focused drives the PTY + * size, so whichever device you are actively using is full-screen. (A shared + * PTY can only be one size; min-sizing letterboxed the bigger screen.) The + * frontend re-sends dims when a pane is shown or its window regains focus, so + * switching devices reclaims full size. No-op after exit (L4) / when unchanged. + */ export function setClientDims( session: Session, ws: WebSocketLike, @@ -200,16 +194,15 @@ export function setClientDims( ): void { if (session.exitedAt !== null) return; session.clientDims.set(ws, { cols, rows }); - applyMinDims(session); + applyDims(session, cols, rows); } /** - * Withdraw a client's size vote without detaching it (its tab was hidden). The - * client still receives output (it's a background mirror); it just stops - * constraining the shared PTY size. Re-votes on its next `setClientDims`. + * Forget a client's size when its tab goes hidden — but do NOT resize: the + * device still actively viewing keeps its size (no letterboxing of a mirror). */ export function clearClientDims(session: Session, ws: WebSocketLike): void { - if (session.clientDims.delete(ws)) applyMinDims(session); + session.clientDims.delete(ws); } /** diff --git a/test/session.test.ts b/test/session.test.ts index d207744..6a44392 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -208,54 +208,51 @@ describe('attachWs', () => { expect(received(b)).toEqual([{ type: 'output', data: 'live' }]); }); - it('resizes the PTY to the MIN dims across active viewers (tmux-style)', () => { + it('attaching alone does NOT change the PTY size (the active device keeps it)', () => { const s = newSession(); // spawn dims 80x24 const wide = createMockWs(); - const narrow = createMockWs(); - - // Attach alone does NOT vote on size (a join could be a hidden mirror). - attachWs(s, wide); - expect(s.pty.cols).toBe(80); - - // A client only constrains the PTY once it reports dims (it's viewing). setClientDims(s, wide, 200, 50); expect(s.pty.cols).toBe(200); + + const hidden = createMockWs(); + attachWs(s, hidden); // joins but never reports dims (background mirror) + // The join must not change the PTY — the active viewer keeps 200x50. + expect(s.pty.cols).toBe(200); + expect(s.pty.rows).toBe(50); + }); + + it('latest-writer-wins: the most recent client dims drive the PTY size', () => { + const s = newSession(); + const desktop = createMockWs(); + const ipad = createMockWs(); + + setClientDims(s, desktop, 200, 50); + expect(s.pty.cols).toBe(200); expect(s.pty.rows).toBe(50); - attachWs(s, narrow); - setClientDims(s, narrow, 90, 30); - // min(200,90)=90, min(50,30)=30 + // The device used next (smaller iPad) takes over — it is now full-screen. + setClientDims(s, ipad, 100, 40); + expect(s.pty.cols).toBe(100); + expect(s.pty.rows).toBe(40); + + // Switching back to the desktop (re-fit on focus) reclaims its size. + setClientDims(s, desktop, 200, 50); + expect(s.pty.cols).toBe(200); + expect(s.pty.rows).toBe(50); + }); + + it('clearClientDims (tab hidden) does NOT resize — the active device keeps its size', () => { + const s = newSession(); + const a = createMockWs(); + const b = createMockWs(); + setClientDims(s, a, 200, 50); + setClientDims(s, b, 90, 30); // b is using it now → 90x30 + + clearClientDims(s, a); // a's tab hidden elsewhere → just forget a's dims + // PTY unchanged: b is still the active viewer at 90x30. expect(s.pty.cols).toBe(90); expect(s.pty.rows).toBe(30); }); - - it('a hidden mirror (no dims reported) does NOT clamp the shared PTY', () => { - const s = newSession(); - const viewer = createMockWs(); - const hidden = createMockWs(); - - attachWs(s, viewer); - setClientDims(s, viewer, 200, 50); - attachWs(s, hidden); // joins but never reports dims (background tab) - - // The hidden mirror must not drag the PTY down to the 80x24 default. - expect(s.pty.cols).toBe(200); - expect(s.pty.rows).toBe(50); - }); - - it('clearClientDims withdraws a vote (tab hidden) and lets the PTY grow', () => { - const s = newSession(); - const wide = createMockWs(); - const narrow = createMockWs(); - attachWs(s, wide); - setClientDims(s, wide, 200, 50); - attachWs(s, narrow); - setClientDims(s, narrow, 90, 30); // clamps to 90x30 - - clearClientDims(s, narrow); // narrow's tab hidden → withdraw vote - expect(s.pty.cols).toBe(200); - expect(s.pty.rows).toBe(50); - }); }); // ── detachWs (remove one client) ────────────────────────────────────────────── @@ -280,19 +277,19 @@ describe('detachWs', () => { expect((s.pty as MockIPty).killed).toBe(false); }); - it('lets the PTY grow back when a small client leaves', () => { + it('does NOT resize when a client leaves (the remaining device keeps its size)', () => { const s = newSession(); const wide = createMockWs(); const narrow = createMockWs(); attachWs(s, wide); setClientDims(s, wide, 200, 50); attachWs(s, narrow); - setClientDims(s, narrow, 90, 30); // clamps to 90x30 + setClientDims(s, narrow, 90, 30); // narrow is the latest writer → 90x30 detachWs(s, narrow, 5_000); - // only the wide client remains → PTY expands back to 200x50 - expect(s.pty.cols).toBe(200); - expect(s.pty.rows).toBe(50); + // detach does not resize; PTY stays at the last set size until a client re-fits. + expect(s.pty.cols).toBe(90); + expect(s.pty.rows).toBe(30); }); it('keeps the PTY alive after the last detach: further onData still fills the buffer', () => {