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.
This commit is contained in:
@@ -67,6 +67,18 @@ 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)
|
||||
|
||||
mountQrConnect(toolbar)
|
||||
|
||||
// PWA: register the service worker (installable + offline shell, M4).
|
||||
|
||||
14
public/manage.html
Normal file
14
public/manage.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!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="./style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="manage-root"></div>
|
||||
<script type="module" src="./build/manage.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
144
public/manage.ts
Normal file
144
public/manage.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 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)
|
||||
141
public/style.css
141
public/style.css
@@ -659,3 +659,144 @@ body {
|
||||
background: var(--red);
|
||||
color: #2a0808;
|
||||
}
|
||||
|
||||
/* ── Session Manager page (manage.html) ──────────────────────────── */
|
||||
#manage-root {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 60px;
|
||||
}
|
||||
.mg-header {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.mg-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
.mg-sub {
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mg-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.mg-btn {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
.mg-btn:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.mg-btn.warn {
|
||||
border-color: rgba(245, 177, 76, 0.5);
|
||||
color: var(--amber);
|
||||
}
|
||||
.mg-btn.danger {
|
||||
border-color: rgba(255, 107, 107, 0.5);
|
||||
color: var(--red);
|
||||
}
|
||||
.mg-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.mg-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.mg-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.mg-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.mg-name {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mg-watch {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
background: var(--surface-3);
|
||||
border-radius: 5px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
.mg-watch.live {
|
||||
color: var(--green);
|
||||
background: rgba(70, 208, 127, 0.14);
|
||||
}
|
||||
.mg-status {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.mg-status.mg-waiting {
|
||||
color: var(--amber);
|
||||
font-weight: 600;
|
||||
}
|
||||
.mg-status.mg-working {
|
||||
color: var(--accent);
|
||||
}
|
||||
.mg-meta {
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
}
|
||||
.mg-actions {
|
||||
flex: none;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.mg-open {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 7px 13px;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mg-open:hover {
|
||||
background: var(--accent-2);
|
||||
}
|
||||
.mg-kill {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 107, 107, 0.4);
|
||||
color: var(--red);
|
||||
border-radius: 8px;
|
||||
padding: 7px 13px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mg-kill:hover {
|
||||
background: rgba(255, 107, 107, 0.14);
|
||||
}
|
||||
.mg-empty {
|
||||
color: var(--text-dim);
|
||||
padding: 30px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -351,6 +351,14 @@ export class TerminalSession {
|
||||
|
||||
hide(): void {
|
||||
this.el.style.display = 'none'
|
||||
// v0.4: withdraw our size vote so a backgrounded mirror doesn't clamp the
|
||||
// shared PTY to this device's size. Output still streams (background mirror).
|
||||
if (this.ws !== null && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(buildMessage({ type: 'blur' }))
|
||||
}
|
||||
// Force show() to re-cast our dims even if unchanged.
|
||||
this.lastCols = -1
|
||||
this.lastRows = -1
|
||||
}
|
||||
|
||||
/** Tear down: closing the WS detaches — the server PTY keeps running. */
|
||||
|
||||
Reference in New Issue
Block a user