feat(web): session detach/undock + beta instance isolation (port 5000)

Detach a session tab into its own browser window and back.

Detach/undock:
- GET /session/:id serves the SPA in "solo mode", reusing the existing
  client (terminal, local-echo overlay, reconnect) so no terminal code is
  duplicated. One PTY already fans out to N SSE/WS clients, so a detached
  window is just another live client — no server fan-out work was needed.
- A pop-out icon per tab; detached tabs show a badge and focus the popup on
  click; closing the popup re-docks. Cross-window state via BroadcastChannel
  plus a WindowProxy poll, and survives a dashboard reload (roll-call).
  app.detachSession(id) is a single idempotent entry point (future gesture
  hook). <base href="/"> so relative assets resolve under /session/:id.

Beta-branch isolation (so it can run alongside a prod Codeman):
- Default port 3000 -> 5000.
- New src/config/instance.ts derives the data dir and tmux socket from
  CODEMAN_INSTANCE (default "beta"): ~/.codeman-beta + tmux -L codeman-beta.
  Every ~/.codeman path now goes through dataPath()/getDataDir() (state,
  mux-sessions, settings, push keys, lifecycle log, screenshots, certs,
  linked-cases, subagent window state). Overridable via CODEMAN_INSTANCE /
  CODEMAN_DATA_DIR / CODEMAN_TMUX_SOCKET. Prevents a second instance from
  discovering and attaching PTYs to the first instance's live tmux sessions.

Verified: tsc / eslint / prettier / lockfile clean; Playwright E2E (27 checks)
for detach/solo/redock; default isolation confirmed to see zero real sessions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-06-06 03:53:54 +02:00
co-authored by Claude Opus 4.8
parent e3c496e1a4
commit b00a680d42
15 changed files with 444 additions and 34 deletions
+222 -1
View File
@@ -295,6 +295,18 @@ class CodemanApp {
this.terminal = null;
this.fitAddon = null;
this.activeSessionId = null;
// ── Session detach / undock (beta) ───────────────────────────────────
// A "solo window" is a popped-out browser window showing exactly one
// session. Detected from the /session/:id URL path (robust even if a cached
// service-worker shell loads), with the server-injected global as a fallback.
this.soloSessionId = this._detectSoloSessionId();
this.isSoloWindow = !!this.soloSessionId;
this.detachedSessions = new Set(); // dashboard-side: ids currently popped out
this.detachedWindows = new Map(); // dashboard-side: id -> WindowProxy
this._detachWatchTimers = new Map(); // dashboard-side: id -> setInterval handle
this.windowChannel = null; // BroadcastChannel for cross-window sync
this._initGeneration = 0; // dedup concurrent handleInit calls
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
this._selectGeneration = 0; // cancel stale selectSession loads
@@ -544,6 +556,11 @@ class CodemanApp {
init() {
// Initialize mobile detection first (adds device classes to body)
MobileDetection.init();
// Detach/undock: open the cross-window sync channel; if this is a solo
// (popped-out) window, apply its minimal chrome immediately so the tab
// strip never flashes before handleInit selects the target session.
this._initWindowChannel();
if (this.isSoloWindow) document.body.classList.add('solo-mode');
// Initialize mobile handlers
KeyboardHandler.init();
SwipeHandler.init();
@@ -776,6 +793,180 @@ class CodemanApp {
} catch { /* non-fatal */ }
}
// ══════════════════════════════════════════════════════════════════════
// Session detach / undock (beta/session-detach)
//
// Each detached window is just another normal client of the same session:
// the server already fans one PTY's output out to N SSE/WS clients and merges
// input from all of them, so a popped-out window is live with no extra server
// plumbing. The dashboard tracks which sessions are out, marks their tabs, and
// re-docks when the window closes. A BroadcastChannel keeps state in sync
// across windows (and survives a dashboard reload via roll-call).
// ══════════════════════════════════════════════════════════════════════
/** Resolve the solo session id from the URL path (preferred) or the
* server-injected global (fallback). Returns null for the normal dashboard. */
_detectSoloSessionId() {
try {
if (typeof window !== 'undefined' && typeof window.__CODEMAN_SOLO__ === 'string' && window.__CODEMAN_SOLO__) {
return window.__CODEMAN_SOLO__;
}
const m = location.pathname.match(/^\/session\/([^/]+)\/?$/);
return m ? decodeURIComponent(m[1]) : null;
} catch { return null; }
}
/**
* Pop a session out into its own browser window. SINGLE, idempotent entry
* point: the tab's pop-out icon calls this, and a future gesture layer
* ("pinch to drop") calls the exact same method — so keep it cheap and
* side-effect-light. Calling it again for an already-open window just raises
* that window.
* @param {string} id session id
*/
detachSession(id) {
if (this.isSoloWindow) return; // a solo window can't spawn more
if (!this.sessions.has(id)) return;
// Already out and still open → just raise it (fast path).
const existing = this.detachedWindows.get(id);
if (existing && !existing.closed) { try { existing.focus(); } catch {} return; }
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
let win = null;
try { win = window.open('/session/' + encodeURIComponent(id), 'codeman-session-' + id, features); } catch {}
if (!win) {
this.showToast?.('Pop-out blocked — allow popups for this site to detach a session', 'error');
return;
}
this.detachedWindows.set(id, win);
this._markDetached(id, true);
this._watchDetachedWindow(id, win);
this._postWindowMessage({ type: 'detached', id });
try { win.focus(); } catch {}
}
/** Re-dock a session: close its window (which re-docks via its unload
* announcement) and clear dashboard state now. */
redockSession(id) {
const win = this.detachedWindows.get(id);
if (win && !win.closed) { try { win.close(); } catch {} }
this._postWindowMessage({ type: 'close-request', id });
this._redock(id);
}
/** Clear all dashboard-side detached state/timers for a session. */
_redock(id) {
const t = this._detachWatchTimers.get(id);
if (t) { clearInterval(t); this._detachWatchTimers.delete(id); }
this.detachedWindows.delete(id);
this._markDetached(id, false);
}
/** Toggle the "detached" marker on a tab (immediate DOM update + state set).
* Full re-renders re-apply the class from this.detachedSessions. */
_markDetached(id, on) {
if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id);
const container = this.$('sessionTabs');
const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`);
if (tab) tab.classList.toggle('detached', on);
}
/** Poll a window we opened; when it closes, re-dock its tab. This is the
* primary (reliable) close-detection path for windows this tab opened. */
_watchDetachedWindow(id, win) {
const prev = this._detachWatchTimers.get(id);
if (prev) clearInterval(prev);
const timer = setInterval(() => {
if (!win || win.closed) {
clearInterval(timer);
this._detachWatchTimers.delete(id);
this._redock(id);
}
}, 800);
this._detachWatchTimers.set(id, timer);
}
/** Open the cross-window BroadcastChannel and wire role-specific handlers. */
_initWindowChannel() {
if (typeof BroadcastChannel === 'undefined') return;
try { this.windowChannel = new BroadcastChannel('codeman-windows'); }
catch { this.windowChannel = null; return; }
this.windowChannel.onmessage = (e) => this._onWindowMessage(e.data);
if (this.isSoloWindow) {
// Announce presence so the dashboard marks this session's tab detached —
// even if this window was opened directly by URL rather than window.open.
this._postWindowMessage({ type: 'detached', id: this.soloSessionId });
// On close, tell the dashboard to re-dock. pagehide is the reliable signal
// on modern browsers; beforeunload is a belt-and-suspenders fallback.
const announceClose = () => this._postWindowMessage({ type: 'redocked', id: this.soloSessionId });
window.addEventListener('pagehide', announceClose);
window.addEventListener('beforeunload', announceClose);
} else {
// Dashboard: ask any already-open solo windows to re-announce themselves
// (covers a dashboard reload while popups remain open).
this._postWindowMessage({ type: 'roll-call' });
}
}
_postWindowMessage(msg) {
try { if (this.windowChannel) this.windowChannel.postMessage(msg); } catch {}
}
_onWindowMessage(msg) {
if (!msg || typeof msg !== 'object') return;
if (this.isSoloWindow) {
// Roll-call has no id (broadcast to all) — answer before the id filter.
if (msg.type === 'roll-call') { this._postWindowMessage({ type: 'detached', id: this.soloSessionId }); return; }
if (msg.id !== this.soloSessionId) return;
if (msg.type === 'close-request') { try { window.close(); } catch {} }
else if (msg.type === 'focus-request') { try { window.focus(); } catch {} }
return;
}
// Dashboard side.
if (msg.type === 'detached' && msg.id) {
this._markDetached(msg.id, true);
} else if (msg.type === 'redocked' && msg.id) {
this._redock(msg.id);
} else if (msg.type === 'detach-request' && msg.id) {
// Future gesture hook: another window asks the dashboard to detach a tab.
this.detachSession(msg.id);
}
}
/** Solo window: select the target session and apply minimal single-session
* chrome. Called from handleInit once the session list has loaded. */
_applySoloMode() {
document.body.classList.add('solo-mode');
const session = this.sessions.get(this.soloSessionId);
if (!session) { this._showSoloSessionGone(); return; }
// Force re-select (handleInit cleared terminal state above).
this.activeSessionId = null;
this.selectSession(this.soloSessionId);
const name = this.getSessionName(session) || 'Session';
const titleEl = document.getElementById('soloSessionTitle');
if (titleEl) { titleEl.textContent = name; titleEl.style.display = ''; }
const redock = document.getElementById('soloRedockBtn');
if (redock) redock.style.display = '';
document.title = name + ' — Codeman';
if (this.notificationManager) this.notificationManager.originalTitle = document.title;
// Neutralize the dashboard-only brand click in a solo window.
const logo = document.querySelector('.header-brand .logo');
if (logo) logo.onclick = (e) => { e.preventDefault(); };
}
/** Solo window: the target session is gone (never existed, or ended while
* this window was open). Show a friendly terminal state. */
_showSoloSessionGone() {
document.body.classList.add('solo-mode');
if (document.querySelector('.solo-gone-overlay')) return;
const el = document.createElement('div');
el.className = 'solo-gone-overlay';
el.innerHTML = '<h2>Session unavailable</h2>'
+ '<p>This session has ended or is no longer available.</p>'
+ '<button class="btn-primary" onclick="window.close()">Close window</button>';
document.body.appendChild(el);
document.title = 'Session ended — Codeman';
}
connectSSE() {
// Check if browser is offline
if (!navigator.onLine) {
@@ -929,6 +1120,12 @@ class CodemanApp {
_onSessionDeleted(data) {
if (this._wsSessionId === data.id) this._disconnectWs();
// Solo window whose session just ended → show the "unavailable" state.
if (this.isSoloWindow && data.id === this.soloSessionId) {
this._showSoloSessionGone();
}
// Dashboard: a detached session ended → clear its detached state/timers.
if (this.detachedSessions.has(data.id)) this._redock(data.id);
this._cleanupSessionData(data.id);
if (this.activeSessionId === data.id) {
this.activeSessionId = null;
@@ -1949,6 +2146,14 @@ class CodemanApp {
// Reset activeSessionId so selectSession doesn't early-return.
// Guard: skip if a newer handleInit has already started (race between loadState + SSE init).
if (gen !== this._initGeneration) return;
// Solo (detached) window: always show exactly the target session, ignoring
// the dashboard's "restore last active" logic.
if (this.isSoloWindow) {
this._applySoloMode();
return;
}
const previousActiveId = this.activeSessionId;
this.activeSessionId = null;
if (this.sessionOrder.length > 0) {
@@ -2182,19 +2387,21 @@ class CodemanApp {
const tallTabsEnabled = this._tallTabsEnabled ?? false;
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}" onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${this.detachedSessions.has(id) ? ' detached' : ''}" data-id="${id}" data-color="${color}" onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info">
<span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : ''}
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
<span class="tab-detached-badge" aria-hidden="true">detached</span>
</span>
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
</span>
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
${subagentBadge}
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span>
<span class="tab-detach" onclick="event.stopPropagation(); app.detachSession('${escapeHtml(id)}')" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span>
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">&times;</span>
</div>`);
_tabIdx++;
@@ -2525,6 +2732,20 @@ class CodemanApp {
}
async selectSession(sessionId) {
// If this session is popped out into its own window, raise that window
// instead of showing it inline (focus-on-click for detached tabs).
if (!this.isSoloWindow && this.detachedSessions.has(sessionId)) {
const win = this.detachedWindows.get(sessionId);
if (win && !win.closed) { try { win.focus(); } catch {} return; }
if (win && win.closed) {
this._redock(sessionId); // ref we own is dead → re-dock, fall through to inline
} else {
// No local ref (dashboard was reloaded). Assume the popup is alive and
// raise it via the channel; its unload announcement will heal the badge.
this._postWindowMessage({ type: 'focus-request', id: sessionId });
return;
}
}
if (this.activeSessionId === sessionId) return;
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
// programmatic focus() within the user-gesture call stack (e.g. tab click).