fix(v0.4): mirror size-clamp bug + session manager page

The mirror looked broken because a backgrounded tab on one device still pinned
the shared PTY to its (default 80x24) size — so the device actively viewing got
a cramped terminal.

Fix: only an ACTIVELY-VIEWING client votes on PTY size.
- attachWs no longer seeds a default size vote (a join may be a hidden mirror)
- new 'blur' client message + clearClientDims(): a tab going hidden withdraws its
  size vote (still mirrors output); show() re-casts it
- PTY size = min cols/rows across clients that have actually reported dims
- session/protocol tests cover hidden-mirror-doesn't-clamp + blur

Session manager (separate page, for the 'too many sessions' problem):
- GET stays; add DELETE /live-sessions/:id and DELETE /live-sessions[?detached=1]
- manager.killById(); LiveSessionInfo gains cols/rows
- public/manage.html + manage.ts: list/open/kill sessions, kill-all / kill-detached,
  auto-refresh; 🗂 toolbar button; bundled as a 2nd esbuild entry

Verified: two concurrent clients mirror output + shared input; manage page
lists/kills (3→2→0). 225 tests green, tsc clean.
This commit is contained in:
Yaojia Wang
2026-06-19 11:04:38 +02:00
parent 22210fadbc
commit 021a514b2d
13 changed files with 467 additions and 44 deletions

View File

@@ -150,16 +150,18 @@ export function createSession(
}
/**
* Add `ws` as a client (multi-device sharing): register its dims, clear the
* detached stamp, re-derive the shared PTY size, then replay the scrollback to
* THIS client only so it sees the current screen. Other clients are untouched.
* Add `ws` as a client (multi-device sharing): clear the detached stamp, then
* 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`.
*/
export function attachWs(session: Session, ws: WebSocketLike, dims: Dims): void {
export function attachWs(session: Session, ws: WebSocketLike): void {
session.clients.add(ws);
session.clientDims.set(ws, dims);
session.detachedAt = null;
applyMinDims(session);
// Replay the buffered scrollback so the joining client sees the last screen.
sendIfOpen(ws, { type: 'output', data: session.buffer.snapshot() });
@@ -201,6 +203,15 @@ export function setClientDims(
applyMinDims(session);
}
/**
* 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`.
*/
export function clearClientDims(session: Session, ws: WebSocketLike): void {
if (session.clientDims.delete(ws)) applyMinDims(session);
}
/**
* Kill the session (idle reclaim). Under tmux this ends the actual shell via
* `tmux kill-session` (H1); killing only the client pty would leave the tmux