chore(web): remove the teammate terminal windows nothing can open

A subagent window could host an xterm attached to a teammate's tmux pane,
but only for a teammate listed in teammatePanesByName, and the SSE
listeners that filled that map were removed in February. Since then the
branch could not be taken, and the code behind it was unreachable:

- initTeammateTerminal and the lazy dispose / restore pair that parked a
  teammate terminal while its window was minimized or hidden;
- the two maps (teammatePanesByName, teammateTerminals) and every loop
  over them: window close and reconnect cleanup, the live font-weight
  save and the skin switch;
- the two early returns in renderSubagentWindowContent that protected a
  terminal which could not exist.

Subagent windows render their activity list exactly as before, and a
teammate still gets its name and badge from the agent description. The
server side is untouched: TeamWatcher still holds respawn back while
teammates are active, and the teammate-pane routes remain.

Tests and docs that described "Agent Teams panes" now name what a font
or skin change really reaches: the main terminal, the tiles and the
split pane.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-13 20:35:32 +01:00
1 parent 259cabd738
commit d44b049c51
9 files changed
+14 -344

No files matched your search

+1 -1
View File
@@ -62,7 +62,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. The one opt-in exception is File Viewer (#759): `mobile.css` no longer hides it at phone width, so it stays off a phone only through the handheld default in `getDefaultSettings()` and the `html.mobile-init` startup rule (both pinned by that test), and shows once the setting is switched on on that phone. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](../../docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): Claude Code's markdown bold is a bare `ESC[1m`, so the weight step is its only cue. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves each slot against **its own** xterm default. ⚠️ The `@font-face` for `fonts/jetbrains-mono-variable.woff2` must stay declared `100 800` (the browser synthesizes from the descriptor, not the file); narrowing it silently makes the setting a no-op. ⚠️ A live save must reach both echo overlays (`refreshFont()`) and open Agent Teams panes. ⚠️ Leave `_awaitTerminalFont()` untouched. Test: `test/terminal-font-weight.test.ts`. → [architecture-invariants#terminal-font-weight](../../docs/architecture-invariants.md#terminal-font-weight)
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): Claude Code's markdown bold is a bare `ESC[1m`, so the weight step is its only cue. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves each slot against **its own** xterm default. ⚠️ The `@font-face` for `fonts/jetbrains-mono-variable.woff2` must stay declared `100 800` (the browser synthesizes from the descriptor, not the file); narrowing it silently makes the setting a no-op. ⚠️ A live save must reach both echo overlays (`refreshFont()`) and every open tile and split pane (`_forEachTile`). ⚠️ Leave `_awaitTerminalFont()` untouched. Test: `test/terminal-font-weight.test.ts`. → [architecture-invariants#terminal-font-weight](../../docs/architecture-invariants.md#terminal-font-weight)
**Theme skins / branding / i18n**: `skin` selects a palette via `data-skin` on `<html>`, applied by an **inline pre-paint script** in `index.html` reading `localStorage['codeman:skin']` to avoid a flash of wrong theme. ⚠️ A skin is **four things that must stay in sync**, and missing any one degrades silently: the `html[data-skin="…"]` token block in `styles.css`, the xterm ANSI palette in `terminal-ui.js`, the pre-paint allowlist, and the Settings picker (both in `index.html`). `test/skin-themes.test.ts` is the static guard. Light skins additionally need `color-scheme: light` and xterm `minimumContrastRatio: 4.5`, and `applyTerminalSkin()` must call the local-echo overlay's `refreshFont()` because it caches the terminal fg/bg. `displayName` changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. `language` (`en`/`zh-CN`) keeps English as the canonical source so live switching stays reversible. User display names flow through `textContent`/attribute APIs and the server title's HTML escaper, never `innerHTML`. → [architecture-invariants#theme-skins](../../docs/architecture-invariants.md#theme-skins)
-4
View File
@@ -855,10 +855,6 @@ class CodemanApp {
// Once set, never recalculated. Persisted to localStorage and server.
this.subagentParentMap = new Map();
// Teammate tmux pane terminals (Agent Teams feature)
this.teammatePanesByName = new Map(); // Map<name, { paneTarget, sessionId, color }>
this.teammateTerminals = new Map(); // Map<agentId, { terminal, fitAddon, paneTarget, sessionId, resizeObserver }>
this.terminalBufferCache = new Map(); // Map<sessionId, string> — client-side cache for instant tab re-visits (max 20)
this.ralphStatePanelCollapsed = true; // Default to collapsed
+2 -160
View File
@@ -1,6 +1,6 @@
/**
* @fileoverview Subagent panel (discovery, detail view, kill), subagent parent tracking,
* agent teams (tasks panel, teammate badges, terminals), project insights (bash tool tracking),
* agent teams (teammate badges), project insights (bash tool tracking),
* file browser (directory tree, preview), log viewer (floating file streamers),
* image popups (auto-popup for screenshots), mux sessions, monitor panel,
* token statistics, toast notifications, and system stats.
@@ -2135,16 +2135,10 @@ Object.assign(CodemanApp.prototype, {
// Show window (unless it was minimized by user)
if (!windowInfo.minimized) {
windowInfo.element.style.display = 'flex';
// Lazily re-create teammate terminal if it was disposed when hidden
if (windowInfo._lazyTerminal) {
this._restoreTeammateTerminalFromLazy(agentId);
}
}
windowInfo.hidden = false;
} else {
// Hide window (but don't close it)
// Dispose teammate terminal to free memory while hidden on inactive tab
this._disposeTeammateTerminalForMinimize(agentId);
windowInfo.element.style.display = 'none';
windowInfo.hidden = true;
}
@@ -2210,23 +2204,12 @@ Object.assign(CodemanApp.prototype, {
windowData.element.remove();
this.subagentWindows.delete(agentId);
}
// Clean up teammate terminal if present
const termData = this.teammateTerminals.get(agentId);
if (termData) {
if (termData.resizeObserver) {
termData.resizeObserver.disconnect();
}
if (termData.terminal) {
try { termData.terminal.dispose(); } catch {}
}
this.teammateTerminals.delete(agentId);
}
},
// Debounced wrapper — coalesces rapid subagent events (tool_call, progress,
// message) into a single DOM update per 100ms per agent window.
scheduleSubagentWindowRender(agentId) {
// Skip DOM updates for windows with lazy (disposed) terminals — they're minimized
// Skip DOM updates for minimized windows
const windowData = this.subagentWindows.get(agentId);
if (windowData?.minimized) return;
@@ -2241,12 +2224,6 @@ Object.assign(CodemanApp.prototype, {
},
renderSubagentWindowContent(agentId) {
// Skip if this window has a live terminal (don't overwrite xterm with activity HTML)
if (this.teammateTerminals.has(agentId)) return;
// Skip if this window has a lazy (disposed) terminal — it will be re-created on restore
const windowData = this.subagentWindows.get(agentId);
if (windowData?._lazyTerminal) return;
const body = document.getElementById(`subagent-window-body-${agentId}`);
if (!body) return;
@@ -2399,141 +2376,6 @@ Object.assign(CodemanApp.prototype, {
// Agent Teams
// ═══════════════════════════════════════════════════════════════
/** Initialize an xterm.js terminal for a teammate's tmux pane */
initTeammateTerminal(agentId, paneInfo, windowElement) {
const body = windowElement.querySelector('.subagent-window-body');
if (!body) return;
// Clear the activity log content
body.innerHTML = '';
body.classList.add('teammate-terminal-body');
windowElement.classList.add('has-terminal');
const sessionId = paneInfo.sessionId;
// Buffer incoming terminal data until xterm is ready
const pendingData = [];
this.teammateTerminals.set(agentId, {
terminal: null,
fitAddon: null,
paneTarget: paneInfo.paneTarget,
sessionId,
resizeObserver: null,
pendingData,
});
// Defer terminal creation to next frame so the body element has computed dimensions
requestAnimationFrame(() => {
// Safety: if window was closed before we got here, bail out
if (!document.contains(body)) {
this.teammateTerminals.delete(agentId);
return;
}
const fontSettings = this.loadAppSettingsFromStorage?.() || {};
const terminal = new Terminal({
theme: { ...window.codemanCurrentXtermTheme() },
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
fontFamily: window.CodemanTerminalFont.resolve(fontSettings.terminalFontFamily),
// A pane opened after a weight change must match the main terminal;
// one open across the change is repainted by applyTerminalFontWeights().
...window.CodemanTerminalFont.resolveWeights(fontSettings),
fontSize: 12,
lineHeight: 1.2,
cursorBlink: true,
cursorStyle: 'block',
scrollback: DEFAULT_SCROLLBACK,
allowTransparency: true,
allowProposedApi: true,
});
const fitAddon = new FitAddon.FitAddon();
terminal.loadAddon(fitAddon);
if (typeof Unicode11Addon !== 'undefined') {
try {
const unicode11Addon = new Unicode11Addon.Unicode11Addon();
terminal.loadAddon(unicode11Addon);
terminal.unicode.activeVersion = '11';
} catch (_e) { /* Unicode11 addon failed */ }
}
try {
terminal.open(body);
} catch (err) {
console.warn('[TeammateTerminal] Failed to open terminal:', err);
this.teammateTerminals.delete(agentId);
return;
}
// Ctrl+Z (SIGTSTP/job-control suspend): a teammate/subagent pane is always
// running an agent CLI (Task-tool dispatched, never a plain shell), so
// unlike the main terminal's mode-gated trap this one is unconditional.
// Mirrors the main terminal's guard in terminal-ui.js's
// attachCustomKeyEventHandler — case-insensitive so Caps Lock (which
// flips ev.key to 'Z' without setting shiftKey) can't slip a suspend past it.
terminal.attachCustomKeyEventHandler((ev) => {
if (
ev.type === 'keydown' &&
ev.key.toLowerCase() === 'z' &&
ev.ctrlKey &&
!ev.altKey &&
!ev.metaKey &&
!ev.shiftKey
) {
ev.preventDefault();
return false;
}
return true;
});
// Wait for terminal renderer to fully initialize before any writes.
// xterm.js needs a few frames after open() before write() is safe.
setTimeout(() => {
try { fitAddon.fit(); } catch {}
// Fetch initial pane buffer
fetch(`/api/sessions/${sessionId}/teammate-pane-buffer/${encodeURIComponent(paneInfo.paneTarget)}`)
.then(r => r.json())
.then(resp => {
if (resp.success && resp.data?.buffer) {
try { terminal.write(resp.data.buffer); } catch {}
}
})
.catch(err => console.error('[TeammateTerminal] Failed to fetch buffer:', err));
// Flush any data that arrived while terminal was initializing
for (const chunk of pendingData) {
try { terminal.write(chunk); } catch {}
}
pendingData.length = 0;
}, 100);
// Forward keyboard input to the teammate's pane
terminal.onData((data) => {
fetch(`/api/sessions/${sessionId}/teammate-pane-input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paneTarget: paneInfo.paneTarget, input: data }),
}).catch(err => console.error('[TeammateTerminal] Failed to send input:', err));
});
// Resize observer to refit terminal when window is resized
const resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => { try { fitAddon.fit(); } catch {} });
});
resizeObserver.observe(body);
// Update the stored entry with the real terminal
const entry = this.teammateTerminals.get(agentId);
if (entry) {
entry.terminal = terminal;
entry.fitAddon = fitAddon;
entry.resizeObserver = resizeObserver;
}
});
},
/** Check if a subagent is a teammate and return its info */
getTeammateInfo(agent) {
if (!agent?.description) return null;
+1 -117
View File
@@ -328,83 +328,6 @@ Object.assign(CodemanApp.prototype, {
this._applyLineEntrances?.(svg);
},
// ═══════════════════════════════════════════════════════════════
// Lazy Terminal Lifecycle
// ═══════════════════════════════════════════════════════════════
//
// Teammate terminal windows use xterm.js Terminal instances that consume
// ~75KB of DOM memory each. With 50 agents minimized, that's ~3.75MB of
// invisible terminals. To avoid this, we dispose the Terminal when a
// window is minimized and lazily re-create it when restored.
//
// Flow:
// minimize → _disposeTeammateTerminalForMinimize() → sets _lazyTerminal flag
// restore → _restoreTeammateTerminalFromLazy() → re-creates Terminal
// create (hidden/minimized) → skip initTeammateTerminal, set _lazyTerminal
//
// The tmux pane buffer is re-fetched from the API on restore. Regular
// (non-teammate) subagent windows use activity HTML and are unaffected
// by this optimization.
/**
* Dispose a teammate terminal when its window is minimized.
* Saves pane metadata so the terminal can be re-created on restore.
* No-op if the window has no teammate terminal.
*/
_disposeTeammateTerminalForMinimize(agentId) {
const termData = this.teammateTerminals.get(agentId);
if (!termData) return; // Not a teammate terminal window
const windowData = this.subagentWindows.get(agentId);
// Save pane metadata needed to re-create the terminal on restore
if (windowData) {
windowData._lazyTerminal = true;
windowData._lazyPaneTarget = termData.paneTarget;
windowData._lazySessionId = termData.sessionId;
}
// Dispose the resize observer
if (termData.resizeObserver) {
termData.resizeObserver.disconnect();
}
// Dispose the xterm.js Terminal instance (frees DOM nodes and internal buffers)
if (termData.terminal) {
try {
termData.terminal.dispose();
} catch {}
}
// Remove from teammateTerminals map so renderSubagentWindowContent won't skip this window
// (the activity HTML can serve as a lightweight placeholder while minimized)
this.teammateTerminals.delete(agentId);
},
/**
* Re-create a teammate terminal when its window is restored from minimized state.
* Fetches the current pane buffer from the API (tmux is the source of truth).
* No-op if the window doesn't have the _lazyTerminal flag.
*/
_restoreTeammateTerminalFromLazy(agentId) {
const windowData = this.subagentWindows.get(agentId);
if (!windowData || !windowData._lazyTerminal) return;
const paneTarget = windowData._lazyPaneTarget;
const sessionId = windowData._lazySessionId;
// Clear lazy state
windowData._lazyTerminal = false;
windowData._lazyPaneTarget = null;
windowData._lazySessionId = null;
if (!paneTarget || !sessionId) return;
// Re-create the terminal using the same initTeammateTerminal flow
const paneInfo = { paneTarget, sessionId };
this.initTeammateTerminal(agentId, paneInfo, windowData.element);
},
// ═══════════════════════════════════════════════════════════════
// Subagent Floating Windows
// ═══════════════════════════════════════════════════════════════
@@ -612,24 +535,7 @@ Object.assign(CodemanApp.prototype, {
win.style.display = 'none';
}
// Render content — check if this teammate has a tmux pane
const paneInfo = teammateInfo ? this.teammatePanesByName.get(teammateInfo.name) : null;
if (paneInfo) {
if (shouldHide) {
// Window starts hidden — defer terminal creation until visible (lazy init).
// Saves ~75KB of DOM memory per hidden teammate terminal window.
const windowEntry = this.subagentWindows.get(agentId);
if (windowEntry) {
windowEntry._lazyTerminal = true;
windowEntry._lazyPaneTarget = paneInfo.paneTarget;
windowEntry._lazySessionId = paneInfo.sessionId;
}
} else {
this.initTeammateTerminal(agentId, paneInfo, win);
}
} else {
this.renderSubagentWindowContent(agentId);
}
this.renderSubagentWindowContent(agentId);
// Focus on click
win.addEventListener('mousedown', () => {
@@ -701,10 +607,6 @@ Object.assign(CodemanApp.prototype, {
this.setAgentParentSessionId(agentId, parentSessionId);
}
// Dispose teammate terminal on minimize to free DOM/memory (~75KB per instance).
// The terminal will be lazily re-created on restore via initTeammateTerminal().
this._disposeTeammateTerminalForMinimize(agentId);
// Always minimize to tab
windowData.element.style.display = 'none';
windowData.minimized = true;
@@ -775,18 +677,6 @@ Object.assign(CodemanApp.prototype, {
}
this.subagentWindows.clear();
// Clean up all teammate terminals
for (const [, termData] of this.teammateTerminals) {
if (termData.resizeObserver) termData.resizeObserver.disconnect();
if (termData.terminal) {
try {
termData.terminal.dispose();
} catch {}
}
}
this.teammateTerminals.clear();
this.teammatePanesByName.clear();
// Clean up all log viewer windows with their EventSources and drag listeners
for (const data of this.logViewerWindows.values()) {
if (data.eventSource) {
@@ -868,12 +758,6 @@ Object.assign(CodemanApp.prototype, {
}
windowData.minimized = false;
// Lazily re-create teammate terminal if it was disposed on minimize.
// Only re-create when the window is actually becoming visible.
if (shouldShow && windowData._lazyTerminal) {
this._restoreTeammateTerminalFromLazy(agentId);
}
this.updateConnectionLines();
// Restack all visible mobile windows so restored ones don't overlap
this.relayoutMobileSubagentWindows();
+3 -24
View File
@@ -428,8 +428,8 @@
// Per-skin xterm.js palettes. The 'daylight-blue' object equals the legacy hardcoded
// theme, so default behavior is unchanged. Shared at module scope and exported on the
// global so both terminal-ui.js (main terminal) and panels-ui.js (teammate terminals,
// a separate IIFE) can read the current skin's palette.
// global so the main terminal and every other module that builds a terminal (tiles,
// the split pane) can read the current skin's palette.
//
// ⚠️ The selection key is `selectionBackground`, xterm's name for it since v5 (#360).
// An ITheme is a plain object handed straight to xterm, so an unknown key is not an
@@ -6585,16 +6585,6 @@ Object.assign(CodemanApp.prototype, {
this._refitAfterCellSizeChange();
this._localEchoOverlay?.refreshFont();
this._predictiveEcho?.refreshFont();
for (const [, entry] of this.teammateTerminals || []) {
if (!entry?.terminal) continue;
entry.terminal.options.fontWeight = fontWeight;
entry.terminal.options.fontWeightBold = fontWeightBold;
try {
entry.fitAddon?.fit();
} catch {
/* pane not laid out yet — its own resize observer refits it */
}
}
this._forEachTile?.((tile) => {
tile.terminal.options.fontWeight = fontWeight;
tile.terminal.options.fontWeightBold = fontWeightBold;
@@ -7158,7 +7148,7 @@ Object.assign(CodemanApp.prototype, {
}, 100);
},
// Re-theme all live xterm terminals (main + teammate) to the given skin's palette.
// Re-theme all live xterm terminals (main pane and tiles) to the given skin's palette.
// Uses the xterm v5+ live setter (full object assignment triggers a repaint for both
// DOM and WebGL renderers) plus a belt-and-suspenders refresh().
applyTerminalSkin(skin) {
@@ -7176,17 +7166,6 @@ Object.assign(CodemanApp.prototype, {
this.terminal.refresh(0, this.terminal.rows - 1);
} catch {}
}
if (this.teammateTerminals) {
for (const [, entry] of this.teammateTerminals) {
if (entry && entry.terminal) {
entry.terminal.options.minimumContrastRatio = minimumContrastRatio;
entry.terminal.options.theme = { ...theme };
try {
entry.terminal.refresh(0, entry.terminal.rows - 1);
} catch {}
}
}
}
this._forEachTile?.((tile) => {
tile.terminal.options.minimumContrastRatio = minimumContrastRatio;
tile.terminal.options.theme = { ...theme };