Files
web-terminal/public/launcher.ts
Yaojia Wang 59de784c8a polish(v0.6): make "New session" a grid tile instead of a heavy header button
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).
2026-06-30 14:25:56 +02:00

136 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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()
},
}
}