feat(v0.5): home session chooser — no auto-created/restored tabs
Opening the app no longer auto-creates a blank tab or auto-restores/discovers sessions as tabs. Instead it lands on a launcher (session chooser): a grid of the host's running sessions as live preview thumbnails — pick one to open (replays its full scrollback), or '+ New session'. Closing the last tab returns to the chooser. - public/launcher.ts: mountLauncher() renders the chooser into the terminal area (live xterm thumbnails via /live-sessions + /preview, 5s refresh), New session + Manage link - tabs.ts: drop restore()/syncLiveSessions() auto-tab behavior; constructor lands on the launcher; rebuild() toggles launcher when tab count is 0; closeTab no longer spawns a blank tab - style.css: .launcher chrome (reuses the .mg-* card/grid/thumbnail styles) Verified: fresh load → 0 tabs, chooser with thumbnails; Open → tab + chooser hides; close last tab → chooser returns. 228 tests green, tsc + build clean.
This commit is contained in:
234
public/launcher.ts
Normal file
234
public/launcher.ts
Normal file
@@ -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<K extends keyof HTMLElementTagNameMap>(
|
||||||
|
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<string, Card>()
|
||||||
|
let timer: ReturnType<typeof setInterval> | 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string>()
|
||||||
|
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()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -660,6 +660,50 @@ body {
|
|||||||
color: #2a0808;
|
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) ──────────────────────────── */
|
/* ── Session Manager page (manage.html) ──────────────────────────── */
|
||||||
#manage-root {
|
#manage-root {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
|
|||||||
127
public/tabs.ts
127
public/tabs.ts
@@ -18,10 +18,10 @@
|
|||||||
|
|
||||||
import { TerminalSession } from './terminal-session.js'
|
import { TerminalSession } from './terminal-session.js'
|
||||||
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
|
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
|
||||||
|
import { mountLauncher, type Launcher } from './launcher.js'
|
||||||
|
|
||||||
const TABS_KEY = 'web-terminal:tabs'
|
const TABS_KEY = 'web-terminal:tabs'
|
||||||
const ACTIVE_KEY = 'web-terminal:active'
|
const ACTIVE_KEY = 'web-terminal:active'
|
||||||
const LEGACY_KEY = 'web-terminal:sessionId' // single-session key from v0.1
|
|
||||||
|
|
||||||
interface TabEntry {
|
interface TabEntry {
|
||||||
session: TerminalSession
|
session: TerminalSession
|
||||||
@@ -46,6 +46,8 @@ export class TabApp {
|
|||||||
private readonly paneHost: HTMLElement
|
private readonly paneHost: HTMLElement
|
||||||
private readonly tabBar: HTMLElement
|
private readonly tabBar: HTMLElement
|
||||||
private readonly approvalBar: HTMLDivElement
|
private readonly approvalBar: HTMLDivElement
|
||||||
|
private readonly launcher: Launcher
|
||||||
|
private launcherVisible = false
|
||||||
|
|
||||||
constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
|
constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
|
||||||
this.paneHost = paneHost
|
this.paneHost = paneHost
|
||||||
@@ -54,7 +56,23 @@ export class TabApp {
|
|||||||
this.approvalBar.id = 'approvalbar'
|
this.approvalBar.id = 'approvalbar'
|
||||||
this.approvalBar.style.display = 'none'
|
this.approvalBar.style.display = 'none'
|
||||||
document.body.appendChild(this.approvalBar)
|
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). */
|
/** 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<void> {
|
|
||||||
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). */
|
/** The active tab's server session id (null until it has attached). */
|
||||||
activeSessionId(): string | null {
|
activeSessionId(): string | null {
|
||||||
return this.tabs[this.activeIndex]?.session.id ?? null
|
return this.tabs[this.activeIndex]?.session.id ?? null
|
||||||
@@ -296,10 +219,11 @@ export class TabApp {
|
|||||||
entry?.session.dispose()
|
entry?.session.dispose()
|
||||||
if (this.editingIndex === i) this.editingIndex = -1
|
if (this.editingIndex === i) this.editingIndex = -1
|
||||||
if (this.tabs.length === 0) {
|
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.persist()
|
||||||
this.rebuild()
|
this.rebuild() // updateLauncher() (in rebuild) shows the chooser
|
||||||
this.activate(0)
|
this.updateApprovalBar()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const next = Math.min(i, this.tabs.length - 1)
|
const next = Math.min(i, this.tabs.length - 1)
|
||||||
@@ -530,5 +454,8 @@ export class TabApp {
|
|||||||
add.setAttribute('aria-label', 'New session')
|
add.setAttribute('aria-label', 'New session')
|
||||||
add.addEventListener('click', () => this.newTab())
|
add.addEventListener('click', () => this.newTab())
|
||||||
this.tabBar.appendChild(add)
|
this.tabBar.appendChild(add)
|
||||||
|
|
||||||
|
// Show/hide the launcher based on whether any tab is open.
|
||||||
|
this.updateLauncher()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user