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:
@@ -92,7 +92,7 @@ export function createSessionManager(cfg: Config): SessionManager {
|
||||
// M4: createSession may throw (spawn failure). Do NOT catch here.
|
||||
// M6: cwd (if given) is the spawn directory for "new tab here".
|
||||
const session = createSession(cfg, dims, now, onSessionExit, undefined, cwd);
|
||||
attachWs(session, ws, dims);
|
||||
attachWs(session, ws);
|
||||
sessions = new Map(sessions).set(session.meta.id, session);
|
||||
return session;
|
||||
}
|
||||
@@ -101,7 +101,7 @@ export function createSessionManager(cfg: Config): SessionManager {
|
||||
|
||||
// ── Case 2: hit a live session → JOIN it (multi-device sharing) ───────────
|
||||
if (existing !== undefined && existing.exitedAt === null) {
|
||||
attachWs(existing, ws, dims);
|
||||
attachWs(existing, ws);
|
||||
return existing;
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ export function createSessionManager(cfg: Config): SessionManager {
|
||||
// to the still-running shell instead of spawning a fresh one.
|
||||
if (cfg.useTmux && hasSession(tmuxName(sessionId))) {
|
||||
const revived = createSession(cfg, dims, now, onSessionExit, sessionId);
|
||||
attachWs(revived, ws, dims);
|
||||
attachWs(revived, ws);
|
||||
sessions = new Map(sessions).set(revived.meta.id, revived);
|
||||
return revived;
|
||||
}
|
||||
@@ -130,7 +130,7 @@ export function createSessionManager(cfg: Config): SessionManager {
|
||||
// ── Case 4: session not found → create a new one ─────────────────────────
|
||||
// M4: createSession may throw. Do NOT catch here.
|
||||
const session = createSession(cfg, dims, now, onSessionExit);
|
||||
attachWs(session, ws, dims);
|
||||
attachWs(session, ws);
|
||||
sessions = new Map(sessions).set(session.meta.id, session);
|
||||
return session;
|
||||
}
|
||||
@@ -149,10 +149,29 @@ export function createSessionManager(cfg: Config): SessionManager {
|
||||
status: s.claudeStatus,
|
||||
exited: s.exitedAt !== null,
|
||||
cwd: s.cwd,
|
||||
cols: s.pty.cols,
|
||||
rows: s.pty.rows,
|
||||
}))
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a session by id (manage page): close every attached client, kill the
|
||||
* PTY (and tmux session, H1), and drop it from the table. Returns whether a
|
||||
* session was found.
|
||||
*/
|
||||
function killById(id: string): boolean {
|
||||
const session = sessions.get(id);
|
||||
if (session === undefined) return false;
|
||||
for (const ws of session.clients) {
|
||||
if (ws.readyState === WS_OPEN) ws.close();
|
||||
}
|
||||
kill(session);
|
||||
sessions = new Map(sessions);
|
||||
sessions.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* H2: a Claude Code hook reported activity for `sessionId`. Record it and push
|
||||
* a `status` frame to the attached ws (no-op if the session is gone/detached).
|
||||
@@ -223,5 +242,5 @@ export function createSessionManager(cfg: Config): SessionManager {
|
||||
sessions = new Map();
|
||||
}
|
||||
|
||||
return { handleAttach, get, list, handleHookEvent, reapIdle, shutdown };
|
||||
return { handleAttach, get, list, killById, handleHookEvent, reapIdle, shutdown };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user