feat(v0.6): remove standalone Manage page — manage sessions on the Sessions view

The Sessions chooser already shows every running session with a live thumbnail
and Open + New session, so fold delete into it and drop the separate page:

- add a Kill ✕ button to each session card (DELETE /live-sessions/:id + refresh),
  reusing makePreviewCard's extraActions and the existing .mg-kill style
- remove public/manage.html + public/manage.ts and the esbuild manage entry
- remove the 🗂 toolbar button (main.ts) and the 🗂 Manage header link (launcher.ts)

Backend DELETE/preview routes stay (used by the launcher kill, thumbnails, and
the project pages). Frontend + build-config only.

Verified: full suite 453 green, web typecheck clean, build:web emits only main
(no manage.* artifacts), and in-browser the Sessions cards have Open + Kill (2→1
on kill), no Manage entry, and GET /manage.html → 404.
This commit is contained in:
Yaojia Wang
2026-06-30 14:08:00 +02:00
parent 46698b8b5e
commit bf5241e87b
6 changed files with 32 additions and 166 deletions

View File

@@ -45,9 +45,7 @@ export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher
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)
actions.append(newBtn)
head.append(sub, actions)
root.append(head)
@@ -57,8 +55,20 @@ export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher
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 {
return makePreviewCard(s, { onOpen: (id) => hooks.onOpen(id) })
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> {

View File

@@ -76,16 +76,8 @@ mountHistory(toolbar, {
mountShortcuts(toolbar)
mountShareSession(toolbar, () => app.activeSessionId())
// Session manager (standalone page) — manage/kill the host's running sessions.
const manageBtn = document.createElement('button')
manageBtn.className = 'toolbtn'
manageBtn.textContent = '🗂'
manageBtn.title = 'Manage sessions (open / kill)'
manageBtn.setAttribute('aria-label', 'Manage sessions')
manageBtn.addEventListener('click', () => {
location.href = '/manage.html'
})
toolbar.appendChild(manageBtn)
// Session management lives on the home Sessions chooser now (open / kill /
// new), so the standalone /manage.html page was removed.
mountQrConnect(toolbar)

View File

@@ -1,15 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Session Manager — Web Terminal</title>
<meta name="theme-color" content="#0e0f13">
<link rel="stylesheet" href="./build/manage.css">
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div id="manage-root"></div>
<script type="module" src="./build/manage.js"></script>
</body>
</html>

View File

@@ -1,135 +0,0 @@
/**
* public/manage.ts — standalone Session Manager page (/manage.html).
*
* A full-page grid of the host's running sessions, each with a LIVE preview
* thumbnail (a read-only xterm rendering the session's current screen, scaled
* down like a screenshot) so you can see what each one is doing at a glance.
* Open one (?join=<id>), kill one, or bulk-kill. Auto-refreshes.
*
* Previews use GET /live-sessions/:id/preview (the scrollback tail) — they do
* NOT open a WS / attach, so they don't inflate watcher counts or keep sessions
* alive. The card + preview plumbing is shared with the launcher via
* public/preview-grid.ts (DRY).
*/
import '@xterm/xterm/css/xterm.css'
import type { LiveSessionInfo } from '../src/types.js'
import {
el,
relTime,
makePreviewCard,
updatePreviewCard,
loadPreviewInto,
fitThumb,
fetchLiveSessions,
type PreviewCard,
} from './preview-grid.js'
const REFRESH_MS = 4000
const THUMB_W = 360 // card thumbnail width in px
const THUMB_MAX_H = 220
const cards = new Map<string, PreviewCard>()
let busy = false
async function killOne(id: string): Promise<void> {
await fetch(`/live-sessions/${id}`, { method: 'DELETE' }).catch(() => {})
void render()
}
async function killBulk(detachedOnly: boolean): Promise<void> {
const label = detachedOnly ? 'all DETACHED sessions (no device watching)' : 'ALL sessions'
if (!confirm(`Kill ${label}? Running shells/Claude will be terminated.`)) return
await fetch(`/live-sessions${detachedOnly ? '?detached=1' : ''}`, { method: 'DELETE' }).catch(() => {})
void render()
}
function makeCard(s: LiveSessionInfo): PreviewCard {
const kill = el('button', 'mg-kill', 'Kill ✕')
kill.addEventListener('click', () => void killOne(s.id))
return makePreviewCard(s, {
onOpen: (id) => {
location.href = `/?join=${id}`
},
openHref: (id) => `/?join=${id}`,
extraActions: () => [kill],
})
}
function updateCard(card: PreviewCard, s: LiveSessionInfo): void {
updatePreviewCard(card, s)
card.meta.textContent = `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old · ${s.id.slice(0, 8)}`
}
const root = document.getElementById('manage-root')
if (!root) throw new Error('#manage-root not found')
let grid: HTMLElement | null = null
let countEl: HTMLElement | null = null
function ensureChrome(): void {
if (grid) return
const header = el('div', 'mg-header')
header.append(el('div', 'mg-title', 'Session Manager'))
countEl = el('div', 'mg-sub', '')
header.append(countEl)
const bar = el('div', 'mg-bar')
const back = el('a', 'mg-btn', '← Back to terminal') as HTMLAnchorElement
back.href = '/'
const refresh = el('button', 'mg-btn', '↻ Refresh')
refresh.addEventListener('click', () => void render())
const killDetached = el('button', 'mg-btn warn', 'Kill detached')
killDetached.addEventListener('click', () => void killBulk(true))
const killAll = el('button', 'mg-btn danger', 'Kill all')
killAll.addEventListener('click', () => void killBulk(false))
bar.append(back, refresh, killDetached, killAll)
header.append(bar)
grid = el('div', 'mg-grid')
root!.append(header, grid)
}
async function render(): Promise<void> {
if (busy) return
busy = true
ensureChrome()
const sessions = await fetchLiveSessions()
if (countEl) countEl.textContent = `${sessions.length} session(s) running on the host`
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)
}
updateCard(card, s)
void loadPreviewInto(s.id, card, THUMB_W, THUMB_MAX_H)
}
// Drop cards for sessions that are gone.
for (const [id, card] of cards) {
if (!seen.has(id)) {
card.term.dispose()
card.el.remove()
cards.delete(id)
}
}
if (sessions.length === 0 && grid && grid.querySelector('.mg-empty') === null) {
grid.append(el('div', 'mg-empty', 'No sessions running. Open the terminal to start one.'))
} else {
grid?.querySelector('.mg-empty')?.remove()
}
busy = false
}
void render()
setInterval(() => void render(), REFRESH_MS)
// Re-scale thumbnails if the window resizes.
window.addEventListener('resize', () => {
for (const card of cards.values()) fitThumb(card, THUMB_W, THUMB_MAX_H)
})