/** * 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. The card + preview plumbing is shared with the launcher via * public/preview-grid.ts (DRY). */ import '@xterm/xterm/css/xterm.css' import type { LiveSessionInfo } from '../src/types.js' import { el, relTime, makePreviewCard, updatePreviewCard, loadPreviewInto, fitThumb, fetchLiveSessions, type PreviewCard, } from './preview-grid.js' const REFRESH_MS = 4000 const THUMB_W = 360 // card thumbnail width in px const THUMB_MAX_H = 220 const cards = new Map() let busy = false 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() } function makeCard(s: LiveSessionInfo): PreviewCard { const kill = el('button', 'mg-kill', 'Kill ✕') kill.addEventListener('click', () => void killOne(s.id)) return makePreviewCard(s, { onOpen: (id) => { location.href = `/?join=${id}` }, openHref: (id) => `/?join=${id}`, extraActions: () => [kill], }) } function updateCard(card: PreviewCard, s: LiveSessionInfo): void { updatePreviewCard(card, s) 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 fetchLiveSessions() 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 loadPreviewInto(s.id, card, THUMB_W, THUMB_MAX_H) } // 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, THUMB_W, THUMB_MAX_H) })