/** * public/preview-grid.ts — shared live-preview thumbnail helpers. * * The launcher (home session chooser) and the manage page both render a grid of * the host's running sessions as read-only xterm thumbnails fed by * GET /live-sessions/:id/preview. This module holds the DRY core: the small DOM * helper, time/status formatting, the preview card factory, the scaling fit, and * the preview fetch — so both pages import it instead of duplicating ~80%. */ import { Terminal } from '@xterm/xterm' import type { LiveSessionInfo } from '../src/types.js' /** Shape of GET /live-sessions/:id/preview. */ export interface SessionPreview { id: string cols: number rows: number data: string } /** Reset screen + scrollback + cursor before writing the tail. */ export const PREVIEW_CLEAR = '\x1b[2J\x1b[3J\x1b[H\x1b[0m' export const PREVIEW_THEME = { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#0e0f13' } /** Create an element with an optional class and text content. */ export 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 } /** Coarse "Ns / Nm / Nh / Nd ago" formatter. */ export 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` } /** Human label for a Claude activity status. */ export function statusText(s: LiveSessionInfo['status']): string { return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·' } /** Display name for a session: last cwd segment, else the short id. */ export function sessionName(s: LiveSessionInfo): string { return s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8) } /** A rendered preview card: the element tree plus the live xterm and its parts. */ export interface PreviewCard { el: HTMLElement term: Terminal inner: HTMLElement thumb: HTMLElement watch: HTMLElement status: HTMLElement meta: HTMLElement } export interface MakeCardOpts { /** Called when the card (thumbnail or Open action) is activated. */ onOpen: (id: string) => void /** Optional extra action button(s) appended to the card actions row. */ extraActions?: (s: LiveSessionInfo) => HTMLElement[] /** Use an Open link instead of a button (manage page). */ openHref?: (id: string) => string } /** Build a preview card for one session (read-only xterm thumbnail). */ export function makePreviewCard(s: LiveSessionInfo, opts: MakeCardOpts): PreviewCard { const cardEl = el('div', 'mg-card') const head = 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}`) head.append(title, status, watch) const thumb = el('div', 'mg-thumb') const inner = el('div', 'mg-thumb-inner') thumb.append(inner) thumb.addEventListener('click', () => opts.onOpen(s.id)) const meta = el('div', 'mg-meta') const actions = el('div', 'mg-actions') if (opts.openHref) { const open = el('a', 'mg-open', 'Open ↗') as HTMLAnchorElement open.href = opts.openHref(s.id) actions.append(open) } else { const open = el('button', 'mg-open', 'Open ↗') open.addEventListener('click', () => opts.onOpen(s.id)) actions.append(open) } if (opts.extraActions) actions.append(...opts.extraActions(s)) 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: PREVIEW_THEME, }) term.open(inner) cardEl.append(head, thumb, meta, actions) return { el: cardEl, term, inner, thumb, watch, status, meta } } /** Re-apply a session's live status/watch/meta fields to its card in place. */ export function updatePreviewCard(card: PreviewCard, s: LiveSessionInfo): void { 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}` } /** Scale the rendered xterm down so the full screen fits `thumbW` px wide. */ export function fitThumb(card: PreviewCard, thumbW: number, thumbMaxH: number): void { const w = card.inner.offsetWidth const h = card.inner.offsetHeight if (w === 0 || h === 0) return const scale = Math.min(1, thumbW / w) card.inner.style.transform = `scale(${scale})` card.thumb.style.height = `${Math.min(h * scale, thumbMaxH)}px` } /** Fetch a session preview, returning null on any failure (best-effort). */ export async function fetchPreview(id: string): Promise { try { const res = await fetch(`/live-sessions/${id}/preview`) if (!res.ok) return null return (await res.json()) as SessionPreview } catch { return null } } /** Render a preview into a card's xterm, resizing + scaling to fit. */ export function renderPreview(card: PreviewCard, p: SessionPreview, thumbW: number, thumbMaxH: number): void { 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(PREVIEW_CLEAR + p.data, () => requestAnimationFrame(() => fitThumb(card, thumbW, thumbMaxH))) } /** Fetch + render a session's preview into its card (best-effort, no throw). */ export async function loadPreviewInto( id: string, card: PreviewCard, thumbW: number, thumbMaxH: number, ): Promise { const p = await fetchPreview(id) if (p) renderPreview(card, p, thumbW, thumbMaxH) } /** Fetch the host's live sessions list, returning [] on failure. */ export async function fetchLiveSessions(): Promise { try { const res = await fetch('/live-sessions') const data: unknown = await res.json() return Array.isArray(data) ? (data as LiveSessionInfo[]) : [] } catch { return [] } }