/** * public/manage.ts — standalone Session Manager page (/manage.html). * * A full-page grid of the host's running sessions, each with a LIVE preview * thumbnail (a read-only xterm rendering the session's current screen, scaled * down like a screenshot) so you can see what each one is doing at a glance. * Open one (?join=), kill one, or bulk-kill. Auto-refreshes. * * Previews use GET /live-sessions/:id/preview (the scrollback tail) — they do * NOT open a WS / attach, so they don't inflate watcher counts or keep sessions * alive. */ import { Terminal } from '@xterm/xterm' import '@xterm/xterm/css/xterm.css' interface LiveSession { id: string createdAt: number clientCount: number status: 'working' | 'waiting' | 'idle' | 'unknown' exited: boolean cwd: string | null cols: number rows: number } interface Preview { id: string cols: number rows: number data: string } const REFRESH_MS = 4000 const THUMB_W = 360 // card thumbnail width in px const THUMB_MAX_H = 220 const CLEAR = '\x1b[2J\x1b[3J\x1b[H\x1b[0m' // reset screen+scrollback+cursor before writing the tail const PREVIEW_THEME = { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#0e0f13' } interface Card { el: HTMLElement term: Terminal inner: HTMLElement thumb: HTMLElement meta: HTMLElement watch: HTMLElement status: HTMLElement } const cards = new Map() let busy = false function el( 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 } 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 statusText(s: LiveSession['status']): string { return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·' } function name(s: LiveSession): string { return s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8) } async function getJSON(url: string): Promise { try { const res = await fetch(url) if (!res.ok) return null return (await res.json()) as T } catch { return null } } async function killOne(id: string): Promise { await fetch(`/live-sessions/${id}`, { method: 'DELETE' }).catch(() => {}) void render() } async function killBulk(detachedOnly: boolean): Promise { const label = detachedOnly ? 'all DETACHED sessions (no device watching)' : 'ALL sessions' if (!confirm(`Kill ${label}? Running shells/Claude will be terminated.`)) return await fetch(`/live-sessions${detachedOnly ? '?detached=1' : ''}`, { method: 'DELETE' }).catch(() => {}) void render() } /** Scale the rendered xterm down so the full screen fits the thumbnail width. */ function fitThumb(card: Card): void { const w = card.inner.offsetWidth const h = card.inner.offsetHeight if (w === 0 || h === 0) return const scale = Math.min(1, THUMB_W / w) card.inner.style.transform = `scale(${scale})` card.thumb.style.height = `${Math.min(h * scale, THUMB_MAX_H)}px` } function makeCard(s: LiveSession): Card { const el0 = el('div', 'mg-card') const head = el('div', 'mg-card-head') const title = el('span', 'mg-name', name(s)) const status = el('span', `mg-status mg-${s.status}`, statusText(s.status)) const watch = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `👁 ${s.clientCount}`) head.append(title, status, watch) const thumb = el('div', 'mg-thumb') const inner = el('div', 'mg-thumb-inner') thumb.append(inner) // Click the preview to open the session full. thumb.addEventListener('click', () => { location.href = `/?join=${s.id}` }) const term = new Terminal({ cols: Math.max(2, s.cols), rows: Math.max(2, s.rows), disableStdin: true, cursorBlink: false, fontFamily: 'Menlo, Consolas, monospace', fontSize: 12, scrollback: 0, theme: PREVIEW_THEME, }) term.open(inner) const meta = el('div', 'mg-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) el0.append(head, thumb, meta, actions) return { el: el0, term, inner, thumb, meta, watch, status } } async function refreshPreview(id: string, card: Card): Promise { const p = await getJSON(`/live-sessions/${id}/preview`) if (!p) return if (card.term.cols !== Math.max(2, p.cols) || card.term.rows !== Math.max(2, p.rows)) { card.term.resize(Math.max(2, p.cols), Math.max(2, p.rows)) } card.term.reset() card.term.write(CLEAR + p.data, () => requestAnimationFrame(() => fitThumb(card))) } function updateCard(card: Card, s: LiveSession): void { card.status.className = `mg-status mg-${s.status}` card.status.textContent = statusText(s.status) card.watch.className = s.clientCount > 0 ? 'mg-watch live' : 'mg-watch' card.watch.textContent = `👁 ${s.clientCount}` card.meta.textContent = `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old · ${s.id.slice(0, 8)}` } const root = document.getElementById('manage-root') if (!root) throw new Error('#manage-root not found') let grid: HTMLElement | null = null let countEl: HTMLElement | null = null function ensureChrome(): void { if (grid) return const header = el('div', 'mg-header') header.append(el('div', 'mg-title', 'Session Manager')) countEl = el('div', 'mg-sub', '') header.append(countEl) 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) grid = el('div', 'mg-grid') root!.append(header, grid) } async function render(): Promise { if (busy) return busy = true ensureChrome() const sessions = (await getJSON('/live-sessions')) ?? [] if (countEl) countEl.textContent = `${sessions.length} session(s) running on the host` const seen = new Set() for (const s of sessions) { seen.add(s.id) let card = cards.get(s.id) if (!card) { card = makeCard(s) cards.set(s.id, card) grid!.append(card.el) } updateCard(card, s) void refreshPreview(s.id, card) } // Drop cards for sessions that are gone. for (const [id, card] of cards) { if (!seen.has(id)) { card.term.dispose() card.el.remove() cards.delete(id) } } if (sessions.length === 0 && grid && grid.querySelector('.mg-empty') === null) { grid.append(el('div', 'mg-empty', 'No sessions running. Open the terminal to start one.')) } else { grid?.querySelector('.mg-empty')?.remove() } busy = false } void render() setInterval(() => void render(), REFRESH_MS) // Re-scale thumbnails if the window resizes. window.addEventListener('resize', () => { for (const card of cards.values()) fitThumb(card) })