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:
144
public/manage.ts
Normal file
144
public/manage.ts
Normal 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)
|
||||
Reference in New Issue
Block a user