/** * 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. The thumbnail card + preview plumbing is shared with the * manage page via public/preview-grid.ts (DRY). */ import type { LiveSessionInfo } from '../src/types.js' import { el, relTime, makePreviewCard, updatePreviewCard, loadPreviewInto, fetchLiveSessions, type PreviewCard, } from './preview-grid.js' export interface LauncherHooks { onOpen: (id: string) => void onNew: () => void } const THUMB_W = 320 const THUMB_MAX_H = 200 const REFRESH_MS = 5000 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', '') head.append(sub) root.append(head) const grid = el('div', 'mg-grid') root.append(grid) // "New session" is a tile that leads the grid (matches the session cards), // instead of a heavy header button. Re-prepended on every refresh. const newTile = el('button', 'mg-new-card') newTile.title = 'Start a new session' newTile.setAttribute('aria-label', 'New session') newTile.append(el('div', 'mg-new-plus', '+'), el('div', 'mg-new-label', 'New session')) newTile.addEventListener('click', () => hooks.onNew()) const cards = new Map() let timer: ReturnType | null = null /** Kill a session (DELETE /live-sessions/:id) then refresh the grid. */ async function killOne(id: string): Promise { await fetch(`/live-sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }).catch(() => {}) void refresh() } function makeCard(s: LiveSessionInfo): PreviewCard { const kill = el('button', 'mg-kill', 'Kill ✕') kill.title = 'Kill this session' kill.addEventListener('click', () => void killOne(s.id)) return makePreviewCard(s, { onOpen: (id) => hooks.onOpen(id), extraActions: () => [kill], }) } async function refresh(): Promise { const sessions = await fetchLiveSessions() sub.textContent = sessions.length ? `${sessions.length} running on this host — pick one to open` : 'No sessions running yet' // Keep the New-session tile as the first grid cell. if (grid.firstChild !== newTile) grid.prepend(newTile) 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) } updatePreviewCard(card, s) card.meta.textContent = `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old` void loadPreviewInto(s.id, card, THUMB_W, THUMB_MAX_H) } for (const [id, card] of cards) { if (!seen.has(id)) { card.term.dispose() card.el.remove() cards.delete(id) } } } 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() }, } }