The big solid-amber "+ New session" button clashed with the refined segmented control. Replace it with a dashed "+" tile that leads the session grid (same size as the session cards, re-prepended each refresh), so it reads as a natural "create" cell. Header is now just the title + Sessions/Projects toggle; the redundant empty-state message is gone (the tile is the CTA). Frontend-only (launcher.ts + style.css). Verified: web typecheck + build clean, preview-grid tests green, in-browser the grid leads with the New-session tile followed by the session cards (Open/Kill).
136 lines
4.0 KiB
TypeScript
136 lines
4.0 KiB
TypeScript
/**
|
||
* 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 } from '../src/types.js'
|
||
import {
|
||
el,
|
||
relTime,
|
||
makePreviewCard,
|
||
updatePreviewCard,
|
||
loadPreviewInto,
|
||
fetchLiveSessions,
|
||
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)
|
||
|
||
// "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<string, PreviewCard>()
|
||
let timer: ReturnType<typeof setInterval> | null = null
|
||
|
||
/** Kill a session (DELETE /live-sessions/:id) then refresh the grid. */
|
||
async function killOne(id: string): Promise<void> {
|
||
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<void> {
|
||
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<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)
|
||
}
|
||
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)) {
|
||
card.term.dispose()
|
||
card.el.remove()
|
||
cards.delete(id)
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
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()
|
||
},
|
||
}
|
||
}
|