/** * 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, OrphanSessionInfo } from '../src/types.js' import { el, relTime, makePreviewCard, updatePreviewCard, loadPreviewInto, fetchLiveSessions, fetchOrphanSessions, fetchOrphanPreview, renderPreview, orphanAsLive, killOrphanReq, cleanupOrphansReq, 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) // A: tmux sessions that outlived the server process. Its own section, below the // live grid, because these are recoveries rather than things you are working on // — and it stays hidden entirely when there are none (the common case, and the // only case when tmux is off). const orphanSection = el('div', 'launcher-orphans') orphanSection.style.display = 'none' const orphanHead = el('div', 'launcher-orphans-head') const orphanTitle = el('div', 'launcher-orphans-title', 'Recoverable') const orphanSub = el('div', 'launcher-orphans-sub', '') const cleanupBtn = el('button', 'launcher-cleanup', 'Clean up idle 7d+') cleanupBtn.title = 'Kill unattached recoverable sessions with no tmux activity for 7 days' orphanHead.append(orphanTitle, orphanSub, cleanupBtn) const orphanGrid = el('div', 'mg-grid') const orphanMore = el('div', 'launcher-orphans-more', '') orphanMore.style.display = 'none' orphanSection.append(orphanHead, orphanGrid, orphanMore) root.append(orphanSection) // "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() const orphanCards = new Map() let timer: ReturnType | null = null const CLEANUP_IDLE_DAYS = 7 /** Thumbnails are xterm instances; a host with dozens of stale tmux sessions * would otherwise mount dozens of terminals on the home screen. */ const MAX_ORPHAN_CARDS = 24 /** 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) } } await refreshOrphans() } /** Kill one orphan, then refresh. */ async function killOrphan(id: string): Promise { await killOrphanReq(id) void refresh() } /** Opening an orphan revives it: attaching with its id re-adopts the tmux * session in place, after which it appears in the live grid above. */ function makeOrphanCard(o: OrphanSessionInfo): PreviewCard { const kill = el('button', 'mg-kill', 'Kill ✕') kill.title = 'End this session’s shell' kill.addEventListener('click', () => void killOrphan(o.id)) return makePreviewCard(orphanAsLive(o), { onOpen: (id) => hooks.onOpen(id), extraActions: () => [kill], }) } async function refreshOrphans(): Promise { const orphans = await fetchOrphanSessions() if (orphans.length === 0) { orphanSection.style.display = 'none' for (const card of orphanCards.values()) { card.term.dispose() card.el.remove() } orphanCards.clear() return } orphanSection.style.display = '' const idle = orphans.filter((o) => !o.attached).length orphanSub.textContent = `${orphans.length} tmux session${orphans.length === 1 ? '' : 's'} not open here — ` + `open one to take it over${idle > 0 ? `, ${idle} unattended` : ''}` // Server-sorted most-recently-active first, so the cap keeps the ones you are // plausibly looking for. The tail is what the cleanup button is for. const shown = orphans.slice(0, MAX_ORPHAN_CARDS) const hidden = orphans.length - shown.length const seen = new Set() for (const o of shown) { seen.add(o.id) let card = orphanCards.get(o.id) const isNew = card === undefined if (card === undefined) { card = makeOrphanCard(o) orphanCards.set(o.id, card) orphanGrid.append(card.el) } updatePreviewCard(card, orphanAsLive(o)) card.meta.textContent = `${o.cols}×${o.rows} · active ${relTime(o.lastActivityAt)} ago · ` + `${relTime(o.createdAt)} old` // ONCE per card, not on every 5 s tick: each preview spawns a tmux // capture-pane, and re-capturing every card every tick would mean dozens of // processes a minute for screens that are, by definition, not being driven // from here. Fire-and-forget so a slow capture never stalls the next card. if (isNew) { const target = card void fetchOrphanPreview(o.id).then((p) => { if (p) renderPreview(target, p, THUMB_W, THUMB_MAX_H) }) } } for (const [id, card] of orphanCards) { if (!seen.has(id)) { card.term.dispose() card.el.remove() orphanCards.delete(id) } } // Never truncate silently. orphanMore.textContent = hidden > 0 ? `${hidden} older session${hidden === 1 ? '' : 's'} not shown — clean up, or attach by id from a terminal with: tmux attach -t web_` : '' orphanMore.style.display = hidden > 0 ? '' : 'none' } cleanupBtn.addEventListener('click', () => { const n = orphanCards.size if ( !window.confirm( `Kill unattached recoverable sessions with no activity for ${CLEANUP_IDLE_DAYS} days?\n\n` + `This ends their shells. Sessions someone is attached to are never touched. ` + `(${n} recoverable session${n === 1 ? '' : 's'} listed.)`, ) ) { return } void cleanupOrphansReq(CLEANUP_IDLE_DAYS).then(() => refresh()) }) 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() for (const card of orphanCards.values()) card.term.dispose() orphanCards.clear() orphanGrid.replaceChildren() orphanSection.style.display = 'none' } }, refresh(): void { void refresh() }, } }