fix(v0.4): full-screen per device — latest-writer-wins PTY sizing

A shared PTY can only be one size; min-sizing clamped the shared session to the
SMALLEST viewer, so a wide desktop got letterboxed when an iPad was also attached
(iPad looked full-screen, desktop did not).

Switch to latest-writer-wins: the device that most recently fit/focused drives the
PTY size, so whichever device you're actively using is full-screen.
- session.ts: setClientDims resizes the PTY directly (drop min across clients);
  attach/detach/blur never resize (the active device keeps its size)
- frontend: TerminalSession.refit() force-resends dims; window 'focus' /
  visibilitychange re-assert the active tab's size, so switching devices reclaims
  full-screen
- tests updated for latest-wins (incl. join/hidden/blur don't resize)

Verified (two ws clients): 200x50 → iPad 100x40 → desktop refit 200x50 → iPad blur
keeps 200x50. 225 tests green.
This commit is contained in:
Yaojia Wang
2026-06-19 11:16:59 +02:00
parent d782ec8488
commit edbfc62f7f
5 changed files with 87 additions and 70 deletions

View File

@@ -45,6 +45,14 @@ app.applySettings(settings)
// Key bar (mobile + desktop) sends to whichever tab is active. // Key bar (mobile + desktop) sends to whichever tab is active.
mountKeybar((data) => app.sendToActive(data)) 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. // Toolbar utilities.
mountSearch(toolbar, { mountSearch(toolbar, {
find: (query, dir) => app.findInActive(query, dir), find: (query, dir) => app.findInActive(query, dir),

View File

@@ -337,6 +337,12 @@ export class TabApp {
this.tabs[this.activeIndex]?.session.send(data) 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). */ /** Apply theme + font settings to every terminal (M3). */
applySettings(s: Settings): void { applySettings(s: Settings): void {
this.settings = s this.settings = s

View File

@@ -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 { hide(): void {
this.el.style.display = 'none' this.el.style.display = 'none'
// v0.4: withdraw our size vote so a backgrounded mirror doesn't clamp the // v0.4: withdraw our size vote so a backgrounded mirror doesn't clamp the

View File

@@ -52,21 +52,9 @@ export function broadcast(session: Session, msg: ServerMessage): void {
for (const ws of session.clients) sendIfOpen(ws, msg); for (const ws of session.clients) sendIfOpen(ws, msg);
} }
/** /** Resize the PTY if the dims actually changed and it's still alive (L4). */
* Resize the PTY to the MIN cols/rows across all attached clients (tmux-style), function applyDims(session: Session, cols: number, rows: number): void {
* so a small phone and a wide laptop sharing the session both see content if (session.exitedAt !== null) return;
* 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;
if (session.pty.cols === cols && session.pty.rows === rows) return; if (session.pty.cols === cols && session.pty.rows === rows) return;
session.pty.resize(cols, rows); 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. * 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). * 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 * Attaching does NOT change the PTY size — the device actively using the
* viewing the session sends a `resize` (the frontend skips hidden panes), so a * session keeps its size. A client only sets the size once it sends a `resize`
* background mirror never clamps the shared PTY. The vote is added on the first * (the frontend does so when its pane is visible / regains focus).
* `setClientDims` and removed on `clearClientDims`/`detachWs`.
*/ */
export function attachWs(session: Session, ws: WebSocketLike): void { export function attachWs(session: Session, ws: WebSocketLike): void {
session.clients.add(ws); 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 { export function detachWs(session: Session, ws: WebSocketLike, now: number): void {
session.clients.delete(ws); session.clients.delete(ws);
session.clientDims.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) { if (session.clients.size === 0) {
session.detachedAt = now; 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 * 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. * 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( export function setClientDims(
session: Session, session: Session,
ws: WebSocketLike, ws: WebSocketLike,
@@ -200,16 +194,15 @@ export function setClientDims(
): void { ): void {
if (session.exitedAt !== null) return; if (session.exitedAt !== null) return;
session.clientDims.set(ws, { cols, rows }); 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 * Forget a client's size when its tab goes hidden — but do NOT resize: the
* client still receives output (it's a background mirror); it just stops * device still actively viewing keeps its size (no letterboxing of a mirror).
* constraining the shared PTY size. Re-votes on its next `setClientDims`.
*/ */
export function clearClientDims(session: Session, ws: WebSocketLike): void { export function clearClientDims(session: Session, ws: WebSocketLike): void {
if (session.clientDims.delete(ws)) applyMinDims(session); session.clientDims.delete(ws);
} }
/** /**

View File

@@ -208,54 +208,51 @@ describe('attachWs', () => {
expect(received(b)).toEqual([{ type: 'output', data: 'live' }]); 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 s = newSession(); // spawn dims 80x24
const wide = createMockWs(); 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); setClientDims(s, wide, 200, 50);
expect(s.pty.cols).toBe(200); 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); expect(s.pty.rows).toBe(50);
attachWs(s, narrow); // The device used next (smaller iPad) takes over — it is now full-screen.
setClientDims(s, narrow, 90, 30); setClientDims(s, ipad, 100, 40);
// min(200,90)=90, min(50,30)=30 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.cols).toBe(90);
expect(s.pty.rows).toBe(30); 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) ────────────────────────────────────────────── // ── detachWs (remove one client) ──────────────────────────────────────────────
@@ -280,19 +277,19 @@ describe('detachWs', () => {
expect((s.pty as MockIPty).killed).toBe(false); 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 s = newSession();
const wide = createMockWs(); const wide = createMockWs();
const narrow = createMockWs(); const narrow = createMockWs();
attachWs(s, wide); attachWs(s, wide);
setClientDims(s, wide, 200, 50); setClientDims(s, wide, 200, 50);
attachWs(s, narrow); 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); detachWs(s, narrow, 5_000);
// only the wide client remains → PTY expands back to 200x50 // detach does not resize; PTY stays at the last set size until a client re-fits.
expect(s.pty.cols).toBe(200); expect(s.pty.cols).toBe(90);
expect(s.pty.rows).toBe(50); expect(s.pty.rows).toBe(30);
}); });
it('keeps the PTY alive after the last detach: further onData still fills the buffer', () => { it('keeps the PTY alive after the last detach: further onData still fills the buffer', () => {