fix: subagent windows not opening — two cascading bugs

1. handleInit crash: commit cdc822d removed Map initializations for
   teammateTerminals, teammatePanesByName, teams, teamTasks, teammateMap
   from the constructor but left cleanup code that iterates them.
   cleanupAllFloatingWindows() crashed on "not iterable", preventing
   ALL frontend data (sessions, subagents) from loading.

2. claudeSessionId null on recovered sessions: only set inside
   startInteractive(), never in constructor or persisted. After server
   restart, recovered sessions had null claudeSessionId, so the
   hasMatchingTab check always failed → no subagent windows.

Fixes:
- Re-add all 5 missing Map initializations in app.js constructor
- Set _claudeSessionId = this.id in Session constructor (Claudeman
  always passes --session-id to Claude, so they always match)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-13 05:07:51 +01:00
co-authored by Claude Opus 4.6
parent 82a0da035c
commit 7d2519cacb
14 changed files with 168 additions and 83 deletions
+67 -14
View File
@@ -15,7 +15,7 @@ const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
const TERMINAL_CHUNK_SIZE = 64 * 1024; // 64KB chunks for terminal data
const TERMINAL_CHUNK_SIZE = 128 * 1024; // 128KB chunks for terminal data
const TERMINAL_TAIL_SIZE = 256 * 1024; // 256KB tail for initial load
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
@@ -1129,6 +1129,8 @@ class ClaudemanApp {
this.terminal = null;
this.fitAddon = null;
this.activeSessionId = null;
this._initGeneration = 0; // dedup concurrent handleInit calls
this._selectGeneration = 0; // cancel stale selectSession loads
this.respawnStatus = {};
this.respawnTimers = {}; // Track timed respawn timers
this.respawnCountdownTimers = {}; // { sessionId: { timerName: { endsAt, totalMs, reason } } }
@@ -1158,6 +1160,15 @@ class ClaudemanApp {
// Once set, never recalculated. Persisted to localStorage and server.
this.subagentParentMap = new Map();
// Agent Teams tracking
this.teams = new Map(); // Map<teamName, TeamConfig>
this.teamTasks = new Map(); // Map<teamName, TeamTask[]>
this.teammateMap = new Map(); // Map<agentId-prefix, {name, color, teamName}> for quick lookup
// 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.ralphStatePanelCollapsed = true; // Default to collapsed
this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel
@@ -1855,10 +1866,10 @@ class ClaudemanApp {
* Write large buffer to terminal in chunks to avoid UI jank.
* Uses requestAnimationFrame to spread work across frames.
* @param {string} buffer - The full terminal buffer to write
* @param {number} chunkSize - Size of each chunk (default 64KB for smooth 60fps)
* @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps)
* @returns {Promise<void>} - Resolves when all chunks written
*/
chunkedTerminalWrite(buffer, chunkSize = 64 * 1024) {
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) {
return new Promise((resolve) => {
if (!buffer || buffer.length === 0) {
resolve();
@@ -3050,6 +3061,8 @@ class ClaudemanApp {
}
handleInit(data) {
const gen = ++this._initGeneration;
// Update version displays (header and toolbar)
if (data.version) {
const versionEl = this.$('versionDisplay');
@@ -3214,6 +3227,8 @@ class ClaudemanApp {
// Restore previously active session (survives page reload + SSE reconnect)
// Must always re-select because handleInit clears terminal state above.
// 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;
const previousActiveId = this.activeSessionId;
this.activeSessionId = null;
if (this.sessionOrder.length > 0) {
@@ -3738,17 +3753,39 @@ class ClaudemanApp {
async selectSession(sessionId) {
if (this.activeSessionId === sessionId) return;
// Instant visual feedback — works even with no network.
// Appended to .main (not terminalContainer) so xterm DOM resets can't remove it.
const selectGen = ++this._selectGeneration;
// Green fade overlay — Web Animations API for reliable GPU compositing.
// (CSS keyframe animations were invisible: black-on-black had zero contrast.)
const main = document.querySelector('.main');
if (main) {
const prev = main.querySelector('.tab-switch-glow');
if (prev) prev.remove();
const glow = document.createElement('div');
glow.className = 'tab-switch-glow';
glow.addEventListener('animationend', () => glow.remove(), { once: true });
main.appendChild(glow);
}
let fade = null;
const fadeOutPromise = new Promise(resolve => {
if (main) {
const prev = main.querySelector('.tab-switch-fade');
if (prev) prev.remove();
fade = document.createElement('div');
fade.className = 'tab-switch-fade';
fade.style.opacity = '0';
fade.style.willChange = 'opacity';
fade.style.transform = 'translateZ(0)'; // force own GPU layer
main.appendChild(fade);
// Web Animations API — runs on compositor thread, bypasses containment issues
const anim = fade.animate(
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 120, easing: 'ease-out', fill: 'forwards' }
);
anim.finished.then(resolve).catch(resolve);
setTimeout(resolve, 350); // safety fallback
} else {
resolve();
}
});
// Yield to browser so it paints the fade overlay at opacity 0 and starts
// animating BEFORE the heavy sync work below (renderSessionTabs etc).
await new Promise(r => requestAnimationFrame(r));
if (selectGen !== this._selectGeneration) return; // newer tab switch won
// Clean up flicker filter state when switching sessions
if (this.flickerFilterTimeout) {
@@ -3798,9 +3835,14 @@ class ClaudemanApp {
// Load terminal buffer for this session
// Use tail mode for faster initial load (256KB is enough for recent visible content)
// Fetch runs in parallel with fade-out; we wait for both before clearing terminal.
try {
const tailSize = 256 * 1024;
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${tailSize}`);
const [res] = await Promise.all([
fetch(`/api/sessions/${sessionId}/terminal?tail=${tailSize}`),
fadeOutPromise,
]);
if (selectGen !== this._selectGeneration) return; // stale — newer selectSession won
const data = await res.json();
this.terminal.clear();
@@ -3812,6 +3854,7 @@ class ClaudemanApp {
}
// Use chunked write for large buffers to avoid UI jank
await this.chunkedTerminalWrite(data.terminalBuffer);
if (selectGen !== this._selectGeneration) return; // stale — skip post-write UI updates
// Ensure terminal is scrolled to bottom after buffer load
this.terminal.scrollToBottom();
}
@@ -3872,8 +3915,18 @@ class ClaudemanApp {
this.terminal.focus();
this.terminal.scrollToBottom();
// Reveal new content — green fade dissolves out via WAAPI
if (fade) {
const revealAnim = fade.animate(
[{ opacity: 1 }, { opacity: 0 }],
{ duration: 120, easing: 'ease-in', fill: 'forwards' }
);
revealAnim.finished.then(() => fade.remove()).catch(() => fade.remove());
}
} catch (err) {
console.error('Failed to load session terminal:', err);
if (fade) fade.remove();
}
}
+5 -11
View File
@@ -1673,22 +1673,16 @@ body {
contain: strict;
will-change: contents;
}
/* Tab switch feedback: green glow emerging from black */
.tab-switch-glow {
/* Tab switch: green-tinted overlay fades in over old content, fades out to reveal new.
Animated via Web Animations API (not CSS keyframes) for reliable GPU compositing.
Green glow is visible against the dark terminal — black-on-black was imperceptible. */
.tab-switch-fade {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 100;
background: radial-gradient(ellipse at center, rgba(34, 197, 94, 0.12) 0%, transparent 70%);
background: radial-gradient(ellipse at center, rgba(20, 80, 40, 0.85) 0%, rgba(0, 5, 2, 0.97) 75%);
box-shadow: inset 0 0 150px 60px rgba(34, 197, 94, 0.35);
animation: tab-switch-glow 0.7s ease-out forwards;
}
@keyframes tab-switch-glow {
0% { opacity: 0; }
15% { opacity: 1; }
100% { opacity: 0; }
}
.terminal-container .xterm {
+1 -1
View File
@@ -1524,7 +1524,7 @@ export class WebServer extends EventEmitter {
// Use writeViaScreen for programmatic input (more reliable for mux sessions)
let success = false;
if (useScreen) {
success = session.writeViaScreen(inputStr);
success = await session.writeViaScreen(inputStr);
if (!success) {
console.warn(`[Server] writeViaScreen failed for session ${id}, falling back to direct write`);
// Fallback to direct write if screen write fails