mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -26,7 +26,7 @@ When user says "COM":
|
||||
1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`)
|
||||
2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web`
|
||||
|
||||
**Version**: 0.1492 (must match `package.json` for npm publish)
|
||||
**Version**: 0.1493 (must match `package.json` for npm publish)
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudeman",
|
||||
"version": "0.1492",
|
||||
"version": "0.1493",
|
||||
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -108,7 +108,7 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
* tmux: `send-keys -l 'text' Enter` (single command, no delay)
|
||||
* screen: `stuff "text"` + 100ms delay + `stuff CR` (with retries)
|
||||
*/
|
||||
sendInput(sessionId: string, input: string): boolean;
|
||||
sendInput(sessionId: string, input: string): Promise<boolean>;
|
||||
|
||||
// ========== Metadata ==========
|
||||
|
||||
|
||||
@@ -1686,11 +1686,11 @@ export class RespawnController extends EventEmitter {
|
||||
this.stepTimer = this.startTrackedTimer(
|
||||
'step-delay',
|
||||
this.config.interStepDelayMs,
|
||||
() => {
|
||||
async () => {
|
||||
this.stepTimer = null;
|
||||
const prompt = this.config.kickstartPrompt!;
|
||||
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
|
||||
this.session.writeViaScreen(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
|
||||
await this.session.writeViaScreen(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
|
||||
this.emit('stepSent', 'kickstart', prompt);
|
||||
this.setState('waiting_kickstart');
|
||||
this.promptDetected = false;
|
||||
@@ -2451,7 +2451,7 @@ export class RespawnController extends EventEmitter {
|
||||
|
||||
this.logAction('command', 'Auto-accept: ↵ Enter (plan approved)');
|
||||
this.emit('stepSent', 'auto-accept', '↵');
|
||||
this.session.writeViaScreen('\r');
|
||||
void this.session.writeViaScreen('\r');
|
||||
this.emit('autoAcceptSent');
|
||||
// Reset so we don't keep spamming Enter if Claude doesn't respond
|
||||
this.hasReceivedOutput = false;
|
||||
@@ -2850,7 +2850,7 @@ export class RespawnController extends EventEmitter {
|
||||
this.stepTimer = this.startTrackedTimer(
|
||||
'step-delay',
|
||||
this.config.interStepDelayMs,
|
||||
() => {
|
||||
async () => {
|
||||
this.stepTimer = null;
|
||||
|
||||
// Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config
|
||||
@@ -2865,7 +2865,7 @@ export class RespawnController extends EventEmitter {
|
||||
|
||||
const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
|
||||
this.logAction('command', `Sending: "${updatePrompt.substring(0, 50)}..."`);
|
||||
this.session.writeViaScreen(input);
|
||||
await this.session.writeViaScreen(input);
|
||||
this.emit('stepSent', 'update', updatePrompt);
|
||||
this.setState('waiting_update');
|
||||
this.promptDetected = false;
|
||||
@@ -2889,10 +2889,10 @@ export class RespawnController extends EventEmitter {
|
||||
this.stepTimer = this.startTrackedTimer(
|
||||
'step-delay',
|
||||
this.config.interStepDelayMs,
|
||||
() => {
|
||||
async () => {
|
||||
this.stepTimer = null;
|
||||
this.logAction('command', 'Sending: /clear');
|
||||
this.session.writeViaScreen('/clear\r'); // \r triggers Enter in Ink/Claude CLI
|
||||
await this.session.writeViaScreen('/clear\r'); // \r triggers Enter in Ink/Claude CLI
|
||||
this.emit('stepSent', 'clear', '/clear');
|
||||
this.setState('waiting_clear');
|
||||
this.promptDetected = false;
|
||||
@@ -2932,10 +2932,10 @@ export class RespawnController extends EventEmitter {
|
||||
this.stepTimer = this.startTrackedTimer(
|
||||
'step-delay',
|
||||
this.config.interStepDelayMs,
|
||||
() => {
|
||||
async () => {
|
||||
this.stepTimer = null;
|
||||
this.logAction('command', 'Sending: /init');
|
||||
this.session.writeViaScreen('/init\r'); // \r triggers Enter in Ink/Claude CLI
|
||||
await this.session.writeViaScreen('/init\r'); // \r triggers Enter in Ink/Claude CLI
|
||||
this.emit('stepSent', 'init', '/init');
|
||||
this.setState('waiting_init');
|
||||
this.promptDetected = false;
|
||||
|
||||
@@ -830,7 +830,7 @@ export class ScreenManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
// Send input directly to screen session using screen -X stuff
|
||||
// This bypasses the attached PTY and sends input directly to the screen
|
||||
sendInput(sessionId: string, input: string): boolean {
|
||||
async sendInput(sessionId: string, input: string): Promise<boolean> {
|
||||
if (IS_TEST_MODE) return true;
|
||||
const screen = this.screens.get(sessionId);
|
||||
if (!screen) {
|
||||
|
||||
+52
-24
@@ -382,6 +382,11 @@ export class Session extends EventEmitter {
|
||||
this.mode = config.mode || 'claude';
|
||||
this._name = config.name || '';
|
||||
this._lastActivityAt = this.createdAt;
|
||||
// Set claudeSessionId immediately — Claudeman always passes --session-id ${this.id}
|
||||
// to Claude CLI, so the Claude session ID always matches the Claudeman session ID.
|
||||
// This ensures subagent matching works even for recovered sessions (where
|
||||
// startInteractive() hasn't been called yet).
|
||||
this._claudeSessionId = this.id;
|
||||
// Support both new (mux) and deprecated (screenManager) parameter names
|
||||
this._mux = config.mux || config.screenManager || null;
|
||||
this._useMux = config.useMux ?? config.useScreen ?? (this._mux !== null && this._mux.isAvailable());
|
||||
@@ -950,10 +955,16 @@ export class Session extends EventEmitter {
|
||||
.replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
|
||||
if (!data) return; // Skip if only filtered sequences
|
||||
|
||||
// Strip ANSI once for all downstream pattern-matching operations
|
||||
// This avoids redundant regex operations in parseTokensFromStatusLine,
|
||||
// parseClaudeCodeInfo, parseTaskDescriptionsFromTerminalData, and working detection
|
||||
const cleanData = data.replace(ANSI_ESCAPE_PATTERN_FULL, '');
|
||||
// Lazy ANSI strip: only compute cleanData when a consumer actually needs it.
|
||||
// During active streaming, many consumers early-exit (ralph disabled, cli info parsed,
|
||||
// no 'token' in data, etc.), so we skip the expensive O(n) regex on most chunks.
|
||||
let _cleanData: string | null = null;
|
||||
const getCleanData = (): string => {
|
||||
if (_cleanData === null) {
|
||||
_cleanData = data.replace(ANSI_ESCAPE_PATTERN_FULL, '');
|
||||
}
|
||||
return _cleanData;
|
||||
};
|
||||
|
||||
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||
this._terminalBuffer.append(data);
|
||||
@@ -962,21 +973,35 @@ export class Session extends EventEmitter {
|
||||
this.emit('terminal', data);
|
||||
this.emit('output', data);
|
||||
|
||||
// Forward to Ralph tracker to detect Ralph loops and todos (pre-stripped)
|
||||
this._ralphTracker.processCleanData(cleanData);
|
||||
// Forward to Ralph tracker to detect Ralph loops and todos
|
||||
// Ralph tracker early-exits when disabled + autoEnableDisabled, skipping ANSI strip
|
||||
if (this._ralphTracker.enabled || !this._ralphTracker.autoEnableDisabled) {
|
||||
this._ralphTracker.processCleanData(getCleanData());
|
||||
}
|
||||
|
||||
// Forward to Bash tool parser to detect file-viewing commands (pre-stripped)
|
||||
this._bashToolParser.processCleanData(cleanData);
|
||||
// Forward to Bash tool parser to detect file-viewing commands
|
||||
// Parser early-exits when disabled, skipping ANSI strip
|
||||
if (this._bashToolParser.enabled) {
|
||||
this._bashToolParser.processCleanData(getCleanData());
|
||||
}
|
||||
|
||||
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
|
||||
this.parseTokensFromStatusLine(cleanData);
|
||||
// Pre-check on raw data: 'token' won't appear in ANSI sequences
|
||||
if (data.includes('token')) {
|
||||
this.parseTokensFromStatusLine(getCleanData());
|
||||
}
|
||||
|
||||
// Parse Claude Code CLI info (version, model, account type) from startup
|
||||
this.parseClaudeCodeInfo(cleanData);
|
||||
// Gated by _cliInfoParsed — only runs during first few chunks
|
||||
if (!this._cliInfoParsed) {
|
||||
this.parseClaudeCodeInfo(getCleanData());
|
||||
}
|
||||
|
||||
// Parse task descriptions from terminal output (e.g., "Explore(Check files)")
|
||||
// This enables correlating subagent windows with their short descriptions
|
||||
this.parseTaskDescriptionsFromTerminalData(cleanData);
|
||||
// Pre-check on raw data: parentheses are safe to check without ANSI strip
|
||||
if (data.includes('(') && data.includes(')')) {
|
||||
this.parseTaskDescriptionsFromTerminalData(getCleanData());
|
||||
}
|
||||
|
||||
// Detect if Claude is working or at prompt
|
||||
// The prompt line contains "❯" when waiting for input
|
||||
@@ -1004,13 +1029,16 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
// Detect when Claude starts working (thinking, writing, etc)
|
||||
// Using pre-cleaned data avoids false positives from window titles like "3 File Reading Task"
|
||||
if (cleanData.includes('Thinking') || cleanData.includes('Writing') ||
|
||||
cleanData.includes('Reading') || cleanData.includes('Running') ||
|
||||
cleanData.includes('⠋') || cleanData.includes('⠙') ||
|
||||
cleanData.includes('⠹') || cleanData.includes('⠸') ||
|
||||
cleanData.includes('⠼') || cleanData.includes('⠴') ||
|
||||
cleanData.includes('⠦') || cleanData.includes('⠧')) {
|
||||
// Fast path: check spinner characters on raw data (Unicode, never in ANSI sequences)
|
||||
const hasSpinner = data.includes('⠋') || data.includes('⠙') ||
|
||||
data.includes('⠹') || data.includes('⠸') ||
|
||||
data.includes('⠼') || data.includes('⠴') ||
|
||||
data.includes('⠦') || data.includes('⠧');
|
||||
// Slow path: check text keywords on clean data (avoids false positives from window titles)
|
||||
const hasWorkKeyword = hasSpinner ||
|
||||
getCleanData().includes('Thinking') || getCleanData().includes('Writing') ||
|
||||
getCleanData().includes('Reading') || getCleanData().includes('Running');
|
||||
if (hasWorkKeyword) {
|
||||
if (!this._isWorking) {
|
||||
this._isWorking = true;
|
||||
this._status = 'busy';
|
||||
@@ -1712,7 +1740,7 @@ export class Session extends EventEmitter {
|
||||
console.log(`[Session] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`);
|
||||
|
||||
// Wait for Claude to be idle before compacting
|
||||
const checkAndCompact = () => {
|
||||
const checkAndCompact = async () => {
|
||||
// Check if session is still valid (not stopped) - must be first check
|
||||
if (this._isStopped) return;
|
||||
if (!this._isCompacting) return;
|
||||
@@ -1725,7 +1753,7 @@ export class Session extends EventEmitter {
|
||||
const compactCmd = this._autoCompactPrompt
|
||||
? `/compact ${this._autoCompactPrompt}\r`
|
||||
: '/compact\r';
|
||||
this.writeViaScreen(compactCmd);
|
||||
await this.writeViaScreen(compactCmd);
|
||||
this.emit('autoCompact', {
|
||||
tokens: totalTokens,
|
||||
threshold: this._autoCompactThreshold,
|
||||
@@ -1766,7 +1794,7 @@ export class Session extends EventEmitter {
|
||||
console.log(`[Session] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`);
|
||||
|
||||
// Wait for Claude to be idle before clearing
|
||||
const checkAndClear = () => {
|
||||
const checkAndClear = async () => {
|
||||
// Check if session is still valid (not stopped) - must be first check
|
||||
if (this._isStopped) return;
|
||||
if (!this._isClearing) return;
|
||||
@@ -1776,7 +1804,7 @@ export class Session extends EventEmitter {
|
||||
if (this._isStopped) return;
|
||||
|
||||
// Send /clear command
|
||||
this.writeViaScreen('/clear\r');
|
||||
await this.writeViaScreen('/clear\r');
|
||||
// Reset token counts
|
||||
this._totalInputTokens = 0;
|
||||
this._totalOutputTokens = 0;
|
||||
@@ -1846,7 +1874,7 @@ export class Session extends EventEmitter {
|
||||
* session.writeViaScreen('/init\r'); // Send /init command
|
||||
* ```
|
||||
*/
|
||||
writeViaScreen(data: string): boolean {
|
||||
async writeViaScreen(data: string): Promise<boolean> {
|
||||
if (this._mux && this._muxSession) {
|
||||
return this._mux.sendInput(this.id, data);
|
||||
}
|
||||
|
||||
+13
-3
@@ -123,7 +123,8 @@ export interface SubagentEvents {
|
||||
|
||||
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude/projects');
|
||||
const IDLE_TIMEOUT_MS = 30000; // Consider agent idle after 30s of no activity
|
||||
const POLL_INTERVAL_MS = 1000; // Check for new files every second
|
||||
const POLL_INTERVAL_MS = 1000; // Base poll interval (lightweight checks)
|
||||
const FULL_SCAN_EVERY_N_POLLS = 5; // Full directory traversal every 5th poll (5s)
|
||||
const LIVENESS_CHECK_MS = 10000; // Check if subagent processes are still alive every 10s
|
||||
const STALE_COMPLETED_MAX_AGE_MS = 60 * 60 * 1000; // Remove completed agents older than 1 hour
|
||||
const STALE_IDLE_MAX_AGE_MS = 4 * 60 * 60 * 1000; // Remove idle agents older than 4 hours
|
||||
@@ -168,6 +169,8 @@ export class SubagentWatcher extends EventEmitter {
|
||||
private pendingToolCalls = new Map<string, Map<string, { toolName: string; timestamp: number }>>();
|
||||
// Guard to prevent concurrent liveness checks (prevents duplicate completed events)
|
||||
private _isCheckingLiveness = false;
|
||||
// Counter for throttling full directory scans (only scan every FULL_SCAN_EVERY_N_POLLS)
|
||||
private _pollCount = 0;
|
||||
// Store error handlers for FSWatchers to enable proper cleanup (prevent memory leaks)
|
||||
private dirWatcherErrorHandlers = new Map<string, (error: Error) => void>();
|
||||
private fileWatcherErrorHandlers = new Map<string, (error: Error) => void>();
|
||||
@@ -208,12 +211,18 @@ export class SubagentWatcher extends EventEmitter {
|
||||
if (this._isRunning) return;
|
||||
this._isRunning = true;
|
||||
|
||||
// Initial scan
|
||||
// Initial scan (always runs immediately)
|
||||
this._pollCount = 0;
|
||||
this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error));
|
||||
|
||||
// Periodic scan for new subagent directories
|
||||
// Full directory traversal only every FULL_SCAN_EVERY_N_POLLS polls (~5s)
|
||||
// FSWatchers handle known directories between full scans
|
||||
this.pollInterval = setInterval(() => {
|
||||
this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error));
|
||||
this._pollCount++;
|
||||
if (this._pollCount % FULL_SCAN_EVERY_N_POLLS === 0) {
|
||||
this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error));
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
// Periodic liveness check for active subagents
|
||||
@@ -338,6 +347,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
this.agentInfo.clear();
|
||||
this.knownSubagentDirs.clear();
|
||||
this.pendingToolCalls.clear();
|
||||
this._pollCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+9
-9
@@ -859,7 +859,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* - `Enter` key is sent as a separate argument (not a shell escape)
|
||||
* - Single command, no delay, no retry loop needed
|
||||
*/
|
||||
sendInput(sessionId: string, input: string): boolean {
|
||||
async sendInput(sessionId: string, input: string): Promise<boolean> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
console.error(`[TmuxManager] sendInput failed: no session found for ${sessionId}. Known: ${Array.from(this.sessions.keys()).join(', ')}`);
|
||||
@@ -886,25 +886,25 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// Send text + Enter in a single command
|
||||
// -l flag = literal text (no special key interpretation)
|
||||
// 'Enter' after -l text = Enter key
|
||||
execSync(
|
||||
await execAsync(
|
||||
`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
{ timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
execSync(
|
||||
await execAsync(
|
||||
`tmux send-keys -t "${session.muxName}" Enter`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
{ timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
} else if (textPart) {
|
||||
// Text only, no Enter
|
||||
execSync(
|
||||
await execAsync(
|
||||
`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
{ timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
} else if (hasCarriageReturn) {
|
||||
// Enter only
|
||||
execSync(
|
||||
await execAsync(
|
||||
`tmux send-keys -t "${session.muxName}" Enter`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
{ timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+67
-14
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -23,7 +23,7 @@ class MockSession extends EventEmitter {
|
||||
this.writeBuffer.push(data);
|
||||
}
|
||||
|
||||
writeViaScreen(data: string): boolean {
|
||||
async writeViaScreen(data: string): Promise<boolean> {
|
||||
this.writeBuffer.push(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export class MockSession extends EventEmitter {
|
||||
}
|
||||
|
||||
/** Write via screen (used by respawn controller) */
|
||||
writeViaScreen(data: string): boolean {
|
||||
async writeViaScreen(data: string): Promise<boolean> {
|
||||
this.writeBuffer.push(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -120,17 +120,17 @@ describe('TmuxManager (unit)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true for registered session (no-op in test mode)', () => {
|
||||
expect(manager.sendInput('test-id', '/clear\r')).toBe(true);
|
||||
it('should return true for registered session (no-op in test mode)', async () => {
|
||||
expect(await manager.sendInput('test-id', '/clear\r')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unknown session', () => {
|
||||
expect(manager.sendInput('nonexistent', 'hello\r')).toBe(false);
|
||||
it('should return false for unknown session', async () => {
|
||||
expect(await manager.sendInput('nonexistent', 'hello\r')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not call any tmux commands in test mode', () => {
|
||||
it('should not call any tmux commands in test mode', async () => {
|
||||
mockedExecSync.mockClear();
|
||||
manager.sendInput('test-id', 'hello\r');
|
||||
await manager.sendInput('test-id', 'hello\r');
|
||||
const sendKeyCalls = mockedExecSync.mock.calls.filter(
|
||||
([cmd]) => typeof cmd === 'string' && cmd.includes('send-keys')
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user