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.
145 lines
4.5 KiB
TypeScript
145 lines
4.5 KiB
TypeScript
/**
|
||
* 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)
|