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

@@ -67,6 +67,18 @@ mountHistory(toolbar, {
})
mountShortcuts(toolbar)
mountShareSession(toolbar, () => app.activeSessionId())
// Session manager (standalone page) — manage/kill the host's running sessions.
const manageBtn = document.createElement('button')
manageBtn.className = 'toolbtn'
manageBtn.textContent = '🗂'
manageBtn.title = 'Manage sessions (open / kill)'
manageBtn.setAttribute('aria-label', 'Manage sessions')
manageBtn.addEventListener('click', () => {
location.href = '/manage.html'
})
toolbar.appendChild(manageBtn)
mountQrConnect(toolbar)
// PWA: register the service worker (installable + offline shell, M4).

14
public/manage.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Session Manager — Web Terminal</title>
<meta name="theme-color" content="#0e0f13">
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div id="manage-root"></div>
<script type="module" src="./build/manage.js"></script>
</body>
</html>

144
public/manage.ts Normal file
View File

@@ -0,0 +1,144 @@
/**
* public/manage.ts — standalone Session Manager page (/manage.html).
*
* Lists the host's running server sessions (GET /live-sessions) and lets you
* open one (?join=<id>), kill one (DELETE /live-sessions/:id), or bulk-kill all
* / only detached ones (DELETE /live-sessions[?detached=1]). Auto-refreshes.
* Independent of the main app, so it works even when many sessions are open.
*/
interface LiveSession {
id: string
createdAt: number
clientCount: number
status: 'working' | 'waiting' | 'idle' | 'unknown'
exited: boolean
cwd: string | null
cols: number
rows: number
}
const REFRESH_MS = 3000
function relTime(ms: number): string {
const s = Math.max(0, (Date.now() - ms) / 1000)
if (s < 60) return `${Math.floor(s)}s`
if (s < 3600) return `${Math.floor(s / 60)}m`
if (s < 86400) return `${Math.floor(s / 3600)}h`
return `${Math.floor(s / 86400)}d`
}
function statusGlyph(s: LiveSession['status']): string {
return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·'
}
async function fetchSessions(): Promise<LiveSession[]> {
try {
const res = await fetch('/live-sessions')
const data: unknown = await res.json()
return Array.isArray(data) ? (data as LiveSession[]) : []
} catch {
return []
}
}
const root = document.getElementById('manage-root')
if (!root) throw new Error('#manage-root not found')
let busy = false
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
cls?: string,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag)
if (cls) node.className = cls
if (text !== undefined) node.textContent = text
return node
}
async function killOne(id: string): Promise<void> {
if (busy) return
busy = true
try {
await fetch(`/live-sessions/${id}`, { method: 'DELETE' })
} catch {
/* best-effort */
}
busy = false
void render()
}
async function killBulk(detachedOnly: boolean): Promise<void> {
if (busy) return
const label = detachedOnly ? 'all DETACHED sessions (no device watching)' : 'ALL sessions'
if (!confirm(`Kill ${label}? Running shells/Claude will be terminated.`)) return
busy = true
try {
await fetch(`/live-sessions${detachedOnly ? '?detached=1' : ''}`, { method: 'DELETE' })
} catch {
/* best-effort */
}
busy = false
void render()
}
function row(s: LiveSession): HTMLElement {
const r = el('div', 'mg-row')
const main = el('div', 'mg-main')
const top = el('div', 'mg-top')
const name = el('span', 'mg-name', s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8))
const watchers = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `👁 ${s.clientCount}`)
const status = el('span', `mg-status mg-${s.status}`, statusGlyph(s.status))
top.append(name, watchers, status)
const meta = el('div', 'mg-meta', `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old · ${s.id.slice(0, 8)}`)
main.append(top, meta)
const actions = el('div', 'mg-actions')
const open = el('a', 'mg-open', 'Open ↗') as HTMLAnchorElement
open.href = `/?join=${s.id}`
const kill = el('button', 'mg-kill', 'Kill ✕')
kill.addEventListener('click', () => void killOne(s.id))
actions.append(open, kill)
r.append(main, actions)
return r
}
async function render(): Promise<void> {
const sessions = await fetchSessions()
root!.replaceChildren()
const header = el('div', 'mg-header')
header.append(el('div', 'mg-title', 'Session Manager'))
const sub = el('div', 'mg-sub', `${sessions.length} session(s) running on the host`)
header.append(sub)
const bar = el('div', 'mg-bar')
const back = el('a', 'mg-btn', '← Back to terminal') as HTMLAnchorElement
back.href = '/'
const refresh = el('button', 'mg-btn', '↻ Refresh')
refresh.addEventListener('click', () => void render())
const killDetached = el('button', 'mg-btn warn', 'Kill detached')
killDetached.addEventListener('click', () => void killBulk(true))
const killAll = el('button', 'mg-btn danger', 'Kill all')
killAll.addEventListener('click', () => void killBulk(false))
bar.append(back, refresh, killDetached, killAll)
header.append(bar)
root!.append(header)
if (sessions.length === 0) {
root!.append(el('div', 'mg-empty', 'No sessions running. Open the terminal to start one.'))
return
}
const list = el('div', 'mg-list')
for (const s of sessions) list.append(row(s))
root!.append(list)
}
void render()
setInterval(() => {
if (!busy) void render()
}, REFRESH_MS)

View File

@@ -659,3 +659,144 @@ body {
background: var(--red);
color: #2a0808;
}
/* ── Session Manager page (manage.html) ──────────────────────────── */
#manage-root {
max-width: 760px;
margin: 0 auto;
padding: 24px 16px 60px;
}
.mg-header {
margin-bottom: 18px;
}
.mg-title {
font-size: 22px;
font-weight: 700;
color: #fff;
}
.mg-sub {
color: var(--text-dim);
font-size: 13px;
margin-top: 2px;
}
.mg-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.mg-btn {
background: var(--surface-2);
border: 1px solid var(--border-strong);
color: var(--text);
border-radius: 8px;
padding: 8px 14px;
cursor: pointer;
font: inherit;
font-size: 13px;
text-decoration: none;
transition: background 0.1s ease;
}
.mg-btn:hover {
background: var(--surface-3);
}
.mg-btn.warn {
border-color: rgba(245, 177, 76, 0.5);
color: var(--amber);
}
.mg-btn.danger {
border-color: rgba(255, 107, 107, 0.5);
color: var(--red);
}
.mg-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.mg-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 10px;
}
.mg-main {
flex: 1 1 auto;
min-width: 0;
}
.mg-top {
display: flex;
align-items: center;
gap: 8px;
}
.mg-name {
color: #fff;
font-weight: 600;
}
.mg-watch {
font-size: 11px;
color: var(--text-faint);
background: var(--surface-3);
border-radius: 5px;
padding: 1px 6px;
}
.mg-watch.live {
color: var(--green);
background: rgba(70, 208, 127, 0.14);
}
.mg-status {
font-size: 11px;
color: var(--text-dim);
}
.mg-status.mg-waiting {
color: var(--amber);
font-weight: 600;
}
.mg-status.mg-working {
color: var(--accent);
}
.mg-meta {
color: var(--text-faint);
font-size: 12px;
margin-top: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: Menlo, Consolas, monospace;
}
.mg-actions {
flex: none;
display: flex;
gap: 6px;
}
.mg-open {
background: var(--accent);
color: #fff;
border-radius: 8px;
padding: 7px 13px;
text-decoration: none;
font-size: 13px;
}
.mg-open:hover {
background: var(--accent-2);
}
.mg-kill {
background: transparent;
border: 1px solid rgba(255, 107, 107, 0.4);
color: var(--red);
border-radius: 8px;
padding: 7px 13px;
cursor: pointer;
font: inherit;
font-size: 13px;
}
.mg-kill:hover {
background: rgba(255, 107, 107, 0.14);
}
.mg-empty {
color: var(--text-dim);
padding: 30px 0;
text-align: center;
}

View File

@@ -351,6 +351,14 @@ export class TerminalSession {
hide(): void {
this.el.style.display = 'none'
// v0.4: withdraw our size vote so a backgrounded mirror doesn't clamp the
// shared PTY to this device's size. Output still streams (background mirror).
if (this.ws !== null && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(buildMessage({ type: 'blur' }))
}
// Force show() to re-cast our dims even if unchanged.
this.lastCols = -1
this.lastRows = -1
}
/** Tear down: closing the WS detaches — the server PTY keeps running. */