Files
web-terminal/public/manage.ts
Yaojia Wang 021a514b2d fix(v0.4): mirror size-clamp bug + session manager page
The mirror looked broken because a backgrounded tab on one device still pinned
the shared PTY to its (default 80x24) size — so the device actively viewing got
a cramped terminal.

Fix: only an ACTIVELY-VIEWING client votes on PTY size.
- attachWs no longer seeds a default size vote (a join may be a hidden mirror)
- new 'blur' client message + clearClientDims(): a tab going hidden withdraws its
  size vote (still mirrors output); show() re-casts it
- PTY size = min cols/rows across clients that have actually reported dims
- session/protocol tests cover hidden-mirror-doesn't-clamp + blur

Session manager (separate page, for the 'too many sessions' problem):
- GET stays; add DELETE /live-sessions/:id and DELETE /live-sessions[?detached=1]
- manager.killById(); LiveSessionInfo gains cols/rows
- public/manage.html + manage.ts: list/open/kill sessions, kill-all / kill-detached,
  auto-refresh; 🗂 toolbar button; bundled as a 2nd esbuild entry

Verified: two concurrent clients mirror output + shared input; manage page
lists/kills (3→2→0). 225 tests green, tsc clean.
2026-06-19 11:04:38 +02:00

145 lines
4.5 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/manage.ts — standalone Session Manager page (/manage.html).
*
* Lists the host's running server sessions (GET /live-sessions) and lets you
* open one (?join=<id>), kill one (DELETE /live-sessions/:id), or bulk-kill all
* / only detached ones (DELETE /live-sessions[?detached=1]). Auto-refreshes.
* Independent of the main app, so it works even when many sessions are open.
*/
interface LiveSession {
id: string
createdAt: number
clientCount: number
status: 'working' | 'waiting' | 'idle' | 'unknown'
exited: boolean
cwd: string | null
cols: number
rows: number
}
const REFRESH_MS = 3000
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 statusGlyph(s: LiveSession['status']): string {
return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·'
}
async function fetchSessions(): Promise<LiveSession[]> {
try {
const res = await fetch('/live-sessions')
const data: unknown = await res.json()
return Array.isArray(data) ? (data as LiveSession[]) : []
} catch {
return []
}
}
const root = document.getElementById('manage-root')
if (!root) throw new Error('#manage-root not found')
let busy = false
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
}
async function killOne(id: string): Promise<void> {
if (busy) return
busy = true
try {
await fetch(`/live-sessions/${id}`, { method: 'DELETE' })
} catch {
/* best-effort */
}
busy = false
void render()
}
async function killBulk(detachedOnly: boolean): Promise<void> {
if (busy) return
const label = detachedOnly ? 'all DETACHED sessions (no device watching)' : 'ALL sessions'
if (!confirm(`Kill ${label}? Running shells/Claude will be terminated.`)) return
busy = true
try {
await fetch(`/live-sessions${detachedOnly ? '?detached=1' : ''}`, { method: 'DELETE' })
} catch {
/* best-effort */
}
busy = false
void render()
}
function row(s: LiveSession): HTMLElement {
const r = el('div', 'mg-row')
const main = el('div', 'mg-main')
const top = el('div', 'mg-top')
const name = el('span', 'mg-name', s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8))
const watchers = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `👁 ${s.clientCount}`)
const status = el('span', `mg-status mg-${s.status}`, statusGlyph(s.status))
top.append(name, watchers, status)
const meta = el('div', 'mg-meta', `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old · ${s.id.slice(0, 8)}`)
main.append(top, meta)
const actions = el('div', 'mg-actions')
const open = el('a', 'mg-open', 'Open ↗') as HTMLAnchorElement
open.href = `/?join=${s.id}`
const kill = el('button', 'mg-kill', 'Kill ✕')
kill.addEventListener('click', () => void killOne(s.id))
actions.append(open, kill)
r.append(main, actions)
return r
}
async function render(): Promise<void> {
const sessions = await fetchSessions()
root!.replaceChildren()
const header = el('div', 'mg-header')
header.append(el('div', 'mg-title', 'Session Manager'))
const sub = el('div', 'mg-sub', `${sessions.length} session(s) running on the host`)
header.append(sub)
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)
root!.append(header)
if (sessions.length === 0) {
root!.append(el('div', 'mg-empty', 'No sessions running. Open the terminal to start one.'))
return
}
const list = el('div', 'mg-list')
for (const s of sessions) list.append(row(s))
root!.append(list)
}
void render()
setInterval(() => {
if (!busy) void render()
}, REFRESH_MS)