/** * public/launcher.ts — the home "session chooser" shown when no tab is open. * * Opening the app no longer auto-creates or auto-restores tabs. Instead this * start screen lists the host's running sessions as live preview thumbnails * (read-only xterm, same as the manage page) so the user picks which to open — * or starts a new one. Sessions persist server-side, so opening one replays its * full scrollback. */ import { Terminal } from '@xterm/xterm' 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 } export interface LauncherHooks { onOpen: (id: string) => void onNew: () => void } const THUMB_W = 320 const THUMB_MAX_H = 200 const REFRESH_MS = 5000 const CLEAR = '\x1b[2J\x1b[3J\x1b[H\x1b[0m' const THEME = { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#0e0f13' } 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 sessionName(s: LiveSession): string { return s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8) } interface Card { el: HTMLElement term: Terminal inner: HTMLElement thumb: HTMLElement watch: HTMLElement status: HTMLElement meta: HTMLElement } export interface Launcher { setVisible(v: boolean): void refresh(): void } export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher { const root = el('div', 'launcher') root.style.display = 'none' host.appendChild(root) const head = el('div', 'launcher-head') head.append(el('div', 'launcher-title', 'Your sessions')) const sub = el('div', 'launcher-sub', '') const actions = el('div', 'launcher-actions') const newBtn = el('button', 'launcher-new', '+ New session') newBtn.addEventListener('click', () => hooks.onNew()) const manage = el('a', 'mg-btn', '🗂 Manage') as HTMLAnchorElement manage.href = '/manage.html' actions.append(newBtn, manage) head.append(sub, actions) root.append(head) const grid = el('div', 'mg-grid') root.append(grid) const cards = new Map() let timer: ReturnType | null = null function fit(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 cardEl = el('div', 'mg-card') const h = el('div', 'mg-card-head') const title = el('span', 'mg-name', sessionName(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}`) h.append(title, status, watch) const thumb = el('div', 'mg-thumb') const inner = el('div', 'mg-thumb-inner') thumb.append(inner) thumb.addEventListener('click', () => hooks.onOpen(s.id)) const meta = el('div', 'mg-meta') const actions2 = el('div', 'mg-actions') const open = el('button', 'mg-open', 'Open ↗') open.addEventListener('click', () => hooks.onOpen(s.id)) actions2.append(open) 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: THEME, }) term.open(inner) cardEl.append(h, thumb, meta, actions2) return { el: cardEl, term, inner, thumb, watch, status, meta } } async function loadPreview(id: string, card: Card): Promise { try { const res = await fetch(`/live-sessions/${id}/preview`) if (!res.ok) return const p = (await res.json()) as Preview 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(() => fit(card))) } catch { /* preview is best-effort */ } } async function refresh(): Promise { let sessions: LiveSession[] = [] try { const res = await fetch('/live-sessions') const data: unknown = await res.json() if (Array.isArray(data)) sessions = data as LiveSession[] } catch { sessions = [] } sub.textContent = sessions.length ? `${sessions.length} running on this host — pick one to open` : 'No sessions running yet' 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) } 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` void loadPreview(s.id, card) } for (const [id, card] of cards) { if (!seen.has(id)) { card.term.dispose() card.el.remove() cards.delete(id) } } grid.querySelector('.mg-empty')?.remove() if (sessions.length === 0) { grid.append(el('div', 'mg-empty', 'No running sessions. Click “New session” to start one.')) } } return { setVisible(v: boolean): void { root.style.display = v ? 'block' : 'none' if (v) { void refresh() if (timer === null) { timer = setInterval(() => { if (root.style.display !== 'none') void refresh() }, REFRESH_MS) } } else { if (timer !== null) { clearInterval(timer) timer = null } for (const card of cards.values()) card.term.dispose() cards.clear() grid.replaceChildren() } }, refresh(): void { void refresh() }, } }