/** * 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, disposeCard, fetchLiveSessions, fetchOrphanSessions, fetchOrphanPreview, countIdleOrphans, 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() /** Ids whose preview has actually RENDERED. Latching on "card exists" instead * left a card that lost its one request (429, or a 404 racing the list) blank * forever, because nothing ever asked again. */ const orphanPreviewed = new Set() let timer: ReturnType | null = null /** Bumped by every teardown and every new refresh. An in-flight refresh compares * it after each await and bails if it is stale — otherwise a slow poll could * resurrect cards for sessions already killed, or re-mount them into a root that * setVisible(false) has just cleared. */ let generation = 0 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)) { disposeCard(card) cards.delete(id) } } await refreshOrphans() } /** Kill one orphan, then refresh. A refusal (403 from the Origin guard behind a * proxy, 404 from a lost race) must be visible — silently doing nothing reads as * a broken button. */ async function killOrphan(id: string): Promise { const ok = await killOrphanReq(id) if (!ok) orphanSub.textContent = `Could not end session ${id.slice(0, 8)} — it may already be gone.` 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 myGen = generation const orphans = await fetchOrphanSessions() if (myGen !== generation) return // torn down or superseded while fetching // null = the request failed. Leave the section exactly as it is: reporting // "none" would tear down every mounted xterm and rebuild it on recovery. if (orphans === null) return if (orphans.length === 0) { orphanSection.style.display = 'none' for (const card of orphanCards.values()) disposeCard(card) orphanCards.clear() orphanPreviewed.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) 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 (!orphanPreviewed.has(o.id)) { const target = card const targetId = o.id void fetchOrphanPreview(targetId).then((p) => { if (p === null || myGen !== generation) return renderPreview(target, p, THUMB_W, THUMB_MAX_H) orphanPreviewed.add(targetId) // only now stop asking }) } } for (const [id, card] of orphanCards) { if (!seen.has(id)) { disposeCard(card) orphanCards.delete(id) orphanPreviewed.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', () => { // Ask the SERVER how many it would end. orphanCards is capped at // MAX_ORPHAN_CARDS while the cleanup matches across every session the server // enumerates, so counting cards would understate the blast radius — on a host // with 69 orphans the dialog would have said 24. void countIdleOrphans(CLEANUP_IDLE_DAYS).then((n) => { if (n === null) { orphanSub.textContent = 'Could not check how many sessions are idle — not cleaning up.' return } if (n === 0) { orphanSub.textContent = `Nothing to clean up: no unattended session has been idle ${CLEANUP_IDLE_DAYS} days.` return } if ( !window.confirm( `End ${n} session${n === 1 ? '' : 's'}?\n\n` + `These are recoverable sessions with no tmux activity for ${CLEANUP_IDLE_DAYS} days. ` + `Their shells will be terminated. Sessions someone is attached to are never touched.`, ) ) { return } void cleanupOrphansReq(CLEANUP_IDLE_DAYS).then((ids) => { if (ids.length === 0) orphanSub.textContent = 'Cleanup ended no sessions.' void 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()) disposeCard(card) cards.clear() grid.replaceChildren() generation += 1 // invalidate any refresh still in flight for (const card of orphanCards.values()) disposeCard(card) orphanCards.clear() orphanPreviewed.clear() orphanGrid.replaceChildren() orphanSection.style.display = 'none' } }, refresh(): void { void refresh() }, } }