diff --git a/public/launcher.ts b/public/launcher.ts new file mode 100644 index 0000000..11965ce --- /dev/null +++ b/public/launcher.ts @@ -0,0 +1,234 @@ +/** + * 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() + }, + } +} diff --git a/public/style.css b/public/style.css index 6861336..47b63e4 100644 --- a/public/style.css +++ b/public/style.css @@ -660,6 +660,50 @@ body { color: #2a0808; } +/* ── Launcher (home session chooser, shown when no tab is open) ──── */ +.launcher { + position: absolute; + inset: 0; + overflow-y: auto; + padding: 22px 18px 40px; + box-sizing: border-box; + background: var(--bg); +} +.launcher-head { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px 16px; + margin-bottom: 18px; +} +.launcher-title { + font-size: 20px; + font-weight: 700; + color: #fff; +} +.launcher-sub { + flex: 1 1 auto; + color: var(--text-dim); + font-size: 13px; +} +.launcher-actions { + display: flex; + gap: 8px; +} +.launcher-new { + background: var(--accent); + color: #fff; + border: none; + border-radius: 8px; + padding: 9px 16px; + font: inherit; + font-weight: 600; + cursor: pointer; +} +.launcher-new:hover { + background: var(--accent-2); +} + /* ── Session Manager page (manage.html) ──────────────────────────── */ #manage-root { max-width: 1200px; diff --git a/public/tabs.ts b/public/tabs.ts index cd3eaba..c2788e5 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -18,10 +18,10 @@ import { TerminalSession } from './terminal-session.js' import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js' +import { mountLauncher, type Launcher } from './launcher.js' const TABS_KEY = 'web-terminal:tabs' const ACTIVE_KEY = 'web-terminal:active' -const LEGACY_KEY = 'web-terminal:sessionId' // single-session key from v0.1 interface TabEntry { session: TerminalSession @@ -46,6 +46,8 @@ export class TabApp { private readonly paneHost: HTMLElement private readonly tabBar: HTMLElement private readonly approvalBar: HTMLDivElement + private readonly launcher: Launcher + private launcherVisible = false constructor(paneHost: HTMLElement, tabBar: HTMLElement) { this.paneHost = paneHost @@ -54,7 +56,23 @@ export class TabApp { this.approvalBar.id = 'approvalbar' this.approvalBar.style.display = 'none' document.body.appendChild(this.approvalBar) - this.restore() + this.launcher = mountLauncher(this.paneHost, { + onOpen: (id) => this.openSession(id), + onNew: () => this.newTab(), + }) + // v0.5: do NOT auto-create or auto-restore tabs. Land on the launcher (the + // session chooser); the user picks which session to open. Sessions persist + // server-side, so opening one replays its full scrollback. + this.rebuild() + this.updateLauncher() + } + + /** Show the launcher (session chooser) iff no tab is open. */ + private updateLauncher(): void { + const empty = this.tabs.length === 0 + if (empty === this.launcherVisible) return + this.launcherVisible = empty + this.launcher.setVisible(empty) } /** Show/hide the approve/reject banner for the active tab's held request (H3). */ @@ -102,101 +120,6 @@ export class TabApp { } } - private restore(): void { - let stored: StoredTab[] = [] - try { - const raw = localStorage.getItem(TABS_KEY) - if (raw) { - const parsed: unknown = JSON.parse(raw) - if (Array.isArray(parsed)) { - stored = parsed.map((v): StoredTab => { - if (typeof v === 'string') return { id: v, title: null } // v0.2.0 format - if (v && typeof v === 'object' && 'id' in v) { - const o = v as { id: unknown; title?: unknown } - return { - id: typeof o.id === 'string' ? o.id : null, - title: typeof o.title === 'string' ? o.title : null, - } - } - return { id: null, title: null } - }) - } - } - } catch { - // ignore malformed state - } - - if (stored.length === 0) { - let legacy: string | null = null - try { - legacy = localStorage.getItem(LEGACY_KEY) - } catch { - legacy = null - } - // v0.2.x single-session migration only; otherwise stay empty and let - // syncLiveSessions() decide (host sessions, or a fresh tab as fallback). - if (legacy) stored = [{ id: legacy, title: null }] - } - - for (const s of stored) this.addEntry(s.id, s.title) - - let active = 0 - try { - active = parseInt(localStorage.getItem(ACTIVE_KEY) ?? '0', 10) - } catch { - active = 0 - } - if (!Number.isInteger(active) || active < 0 || active >= this.tabs.length) active = 0 - this.rebuild() - if (this.tabs.length > 0) this.activate(active) - - // v0.4: discover the host's running sessions and show them as tabs too. - void this.syncLiveSessions() - } - - /** - * v0.4 multi-device: fetch the host's currently-running sessions and add a tab - * for each one not already open, so opening the app on any device shows (and - * joins/mirrors) everything running on the host. Falls back to one fresh tab - * when nothing is stored and nothing is live. - */ - private async syncLiveSessions(): Promise { - let live: Array<{ id: string }> = [] - try { - const res = await fetch('/live-sessions') - const data: unknown = await res.json() - if (Array.isArray(data)) live = data as Array<{ id: string }> - } catch { - live = [] - } - - const open = new Set( - this.tabs.map((t) => t.session.id).filter((x): x is string => typeof x === 'string'), - ) - let added = false - for (const info of live) { - if (typeof info.id === 'string' && !open.has(info.id)) { - this.addEntry(info.id, null) - added = true - } - } - // Nothing restored and nothing live → start one fresh session. - if (this.tabs.length === 0) { - this.addEntry(null, null) - added = true - } - if (!added) return - - this.rebuild() - const idx = - this.activeIndex >= 0 && this.activeIndex < this.tabs.length ? this.activeIndex : 0 - this.activeIndex = idx - this.tabs.forEach((t, i) => (i === idx ? t.session.show() : t.session.hide())) - this.tabs.forEach((t) => this.refreshTab(t)) - this.updateApprovalBar() - this.persist() - } - /** The active tab's server session id (null until it has attached). */ activeSessionId(): string | null { return this.tabs[this.activeIndex]?.session.id ?? null @@ -296,10 +219,11 @@ export class TabApp { entry?.session.dispose() if (this.editingIndex === i) this.editingIndex = -1 if (this.tabs.length === 0) { - this.addEntry(null, null) + // v0.5: closing the last tab returns to the launcher — no auto-blank tab. + this.activeIndex = -1 this.persist() - this.rebuild() - this.activate(0) + this.rebuild() // updateLauncher() (in rebuild) shows the chooser + this.updateApprovalBar() return } const next = Math.min(i, this.tabs.length - 1) @@ -530,5 +454,8 @@ export class TabApp { add.setAttribute('aria-label', 'New session') add.addEventListener('click', () => this.newTab()) this.tabBar.appendChild(add) + + // Show/hide the launcher based on whether any tab is open. + this.updateLauncher() } }