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:
127
public/tabs.ts
127
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<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). */
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user