diff --git a/apps/frontend/src/renderer/components/Terminal.tsx b/apps/frontend/src/renderer/components/Terminal.tsx index 463b8f5d1e..1f2d2672a4 100644 --- a/apps/frontend/src/renderer/components/Terminal.tsx +++ b/apps/frontend/src/renderer/components/Terminal.tsx @@ -3,7 +3,7 @@ import { useDroppable, useDndContext } from '@dnd-kit/core'; import '@xterm/xterm/css/xterm.css'; import { FileDown } from 'lucide-react'; import { cn } from '../lib/utils'; -import { useTerminalStore } from '../stores/terminal-store'; +import { useTerminalStore, enqueueAutoResume, dequeueAutoResume } from '../stores/terminal-store'; import { useSettingsStore } from '../stores/settings-store'; import { useToast } from '../hooks/use-toast'; import type { TerminalProps } from './terminal/types'; @@ -381,6 +381,17 @@ export const Terminal = forwardRef(function Termi } pendingWorktreeConfigRef.current = null; } + // Auto-resume: enqueue non-active terminals for staggered resume + // Read current active state from store to avoid stale closure value + const currentActiveId = useTerminalStore.getState().activeTerminalId; + const isCurrentlyActive = currentActiveId === id; + + if (!isCurrentlyActive) { + const currentTerminal = useTerminalStore.getState().terminals.find(t => t.id === id); + if (currentTerminal?.pendingClaudeResume) { + enqueueAutoResume(id); + } + } }, onError: (error) => { // Clear pending config on error to prevent stale config from being applied @@ -573,6 +584,8 @@ export const Terminal = forwardRef(function Termi // Check if both conditions are met for auto-resume if (isActive && terminal?.pendingClaudeResume) { + // Remove from queue since active terminal handles its own resume + dequeueAutoResume(id); // Defer the resume slightly to ensure all React state updates have propagated // This fixes the race condition where isActive and pendingClaudeResume might update // at different times during the restoration flow @@ -630,6 +643,7 @@ export const Terminal = forwardRef(function Termi return () => { isMountedRef.current = false; + dequeueAutoResume(id); cleanupAutoNaming(); // Clear post-creation dimension check timeout to prevent operations on unmounted component diff --git a/apps/frontend/src/renderer/stores/terminal-store.ts b/apps/frontend/src/renderer/stores/terminal-store.ts index ffdc246992..baa76b0c5e 100644 --- a/apps/frontend/src/renderer/stores/terminal-store.ts +++ b/apps/frontend/src/renderer/stores/terminal-store.ts @@ -76,6 +76,118 @@ export function writeToTerminal(terminalId: string, data: string): void { } } +// === Auto-Resume Queue Coordinator === +// Coordinates staggered auto-resume of non-active terminals after app restart. +// Each terminal enqueues itself when its PTY is confirmed ready (onCreated). +// A single coordinator processes the queue with stagger delays. + +const AUTO_RESUME_INITIAL_DELAY_MS = 1500; +const AUTO_RESUME_STAGGER_MS = 500; + +// Auto-resume queue state (single-threaded JS assumption - see processAutoResumeQueue) +let autoResumeQueue: string[] = []; +let autoResumeTimer: ReturnType | null = null; +let autoResumeProcessing = false; +let autoResumeGeneration = 0; // Generation counter to abort stale processing runs +let isResumingAll = false; // Concurrency guard for resumeAllPendingClaude + +export function enqueueAutoResume(terminalId: string): void { + if (autoResumeQueue.includes(terminalId)) return; + autoResumeQueue.push(terminalId); + debugLog(`[AutoResume] Enqueued terminal: ${terminalId}, queue size: ${autoResumeQueue.length}`); + + // Start initial delay timer on first enqueue only + if (autoResumeTimer === null && !autoResumeProcessing) { + debugLog(`[AutoResume] Starting initial delay (${AUTO_RESUME_INITIAL_DELAY_MS}ms)`); + autoResumeTimer = setTimeout(() => { + autoResumeTimer = null; + processAutoResumeQueue(); + }, AUTO_RESUME_INITIAL_DELAY_MS); + } +} + +export function dequeueAutoResume(terminalId: string): void { + const idx = autoResumeQueue.indexOf(terminalId); + if (idx !== -1) { + autoResumeQueue.splice(idx, 1); + debugLog(`[AutoResume] Dequeued terminal: ${terminalId}, queue size: ${autoResumeQueue.length}`); + } +} + +export function clearAutoResumeQueue(): void { + autoResumeQueue = []; + if (autoResumeTimer !== null) { + clearTimeout(autoResumeTimer); + autoResumeTimer = null; + } + // Increment generation first to abort any in-flight processing + autoResumeGeneration++; + // Reset processing flag so new enqueue attempts can start a fresh processor + // Safe to do after generation bump because stale runs check generation on each iteration + autoResumeProcessing = false; + debugLog('[AutoResume] Queue cleared'); +} + +/** + * Shared helper to resume a terminal's Claude session with consistent behavior. + * Clears the pending flag and triggers IPC activation. + */ +function resumeTerminalClaudeSession(terminalId: string): void { + useTerminalStore.getState().setPendingClaudeResume(terminalId, false); + window.electronAPI.activateDeferredClaudeResume(terminalId); +} + +async function processAutoResumeQueue(): Promise { + if (autoResumeProcessing) return; + autoResumeProcessing = true; + const generation = autoResumeGeneration; // Capture generation to detect cancellation + debugLog(`[AutoResume] Processing queue (generation ${generation}), ${autoResumeQueue.length} terminals`); + + try { + while (autoResumeQueue.length > 0) { + // Check if this processing run has been cancelled + if (generation !== autoResumeGeneration) { + debugLog(`[AutoResume] Generation mismatch (${generation} !== ${autoResumeGeneration}) — aborting stale processing`); + return; + } + + const terminalId = autoResumeQueue.shift()!; + + // Check if terminal still needs resume + const terminal = useTerminalStore.getState().terminals.find(t => t.id === terminalId); + if (!terminal?.pendingClaudeResume) { + debugLog(`[AutoResume] Skipping ${terminalId} — no longer pending`); + continue; + } + + try { + debugLog(`[AutoResume] Resuming terminal: ${terminalId}`); + resumeTerminalClaudeSession(terminalId); + } catch (error) { + // Log error and continue processing remaining terminals + debugError(`[AutoResume] Error resuming terminal ${terminalId}:`, error); + } + + // Stagger delay between resumes + if (autoResumeQueue.length > 0) { + await new Promise(resolve => setTimeout(resolve, AUTO_RESUME_STAGGER_MS)); + // Re-check generation after await (may have been cancelled during stagger delay) + if (generation !== autoResumeGeneration) { + debugLog(`[AutoResume] Generation mismatch after stagger — aborting stale processing`); + return; + } + } + } + + debugLog('[AutoResume] Queue processing complete'); + } finally { + // Only reset processing flag if this is still the current generation + if (generation === autoResumeGeneration) { + autoResumeProcessing = false; + } + } +} + export type TerminalStatus = 'idle' | 'running' | 'claude-active' | 'exited'; export interface Terminal { @@ -434,35 +546,73 @@ export const useTerminalStore = create((set, get) => ({ }, resumeAllPendingClaude: async () => { - const state = get(); - - // Filter terminals with pending Claude resume - const pendingTerminals = state.terminals.filter(t => t.pendingClaudeResume === true); - - if (pendingTerminals.length === 0) { - debugLog('[TerminalStore] No terminals with pending Claude resume'); + // Concurrency guard - prevent multiple simultaneous executions + if (isResumingAll) { + debugLog('[TerminalStore] Resume All already in progress — skipping'); return; } + isResumingAll = true; + + // Capture generation BEFORE clearAutoResumeQueue() bumps it, so this call + // doesn't immediately invalidate itself, but CAN be cancelled by external calls + const generation = autoResumeGeneration; - debugLog(`[TerminalStore] Resuming ${pendingTerminals.length} pending Claude sessions with 500ms stagger`); + try { + // Clear auto-resume queue to prevent redundant processing + clearAutoResumeQueue(); - // Iterate through terminals with staggered delays - for (let i = 0; i < pendingTerminals.length; i++) { - const terminal = pendingTerminals[i]; - // Clear the pending flag BEFORE IPC call to prevent race condition - // with auto-resume effect in Terminal.tsx (which checks this flag on a 100ms timeout) - get().setPendingClaudeResume(terminal.id, false); + const state = get(); - debugLog(`[TerminalStore] Activating deferred Claude resume for terminal: ${terminal.id}`); - window.electronAPI.activateDeferredClaudeResume(terminal.id); + // Filter terminals with pending Claude resume + const pendingTerminals = state.terminals.filter(t => t.pendingClaudeResume === true); - // Wait 500ms before processing next terminal (staggered delay) - if (i < pendingTerminals.length - 1) { - await new Promise(resolve => setTimeout(resolve, 500)); + if (pendingTerminals.length === 0) { + debugLog('[TerminalStore] No terminals with pending Claude resume'); + return; } - } - debugLog('[TerminalStore] Completed resuming all pending Claude sessions'); + debugLog(`[TerminalStore] Resuming ${pendingTerminals.length} pending Claude sessions with ${AUTO_RESUME_STAGGER_MS}ms stagger (generation ${generation})`); + + // Iterate through terminals with staggered delays + for (let i = 0; i < pendingTerminals.length; i++) { + // Check for cancellation (e.g., project switch triggered clearAutoResumeQueue) + if (generation !== autoResumeGeneration) { + debugLog(`[TerminalStore] Generation mismatch in resumeAll (${generation} !== ${autoResumeGeneration}) — aborting`); + return; + } + + const terminal = pendingTerminals[i]; + + // Re-check terminal still needs resume (may have changed during stagger delay) + const currentTerminal = get().terminals.find(t => t.id === terminal.id); + if (!currentTerminal?.pendingClaudeResume) { + debugLog(`[TerminalStore] Skipping ${terminal.id} — no longer pending`); + continue; + } + + try { + debugLog(`[TerminalStore] Activating deferred Claude resume for terminal: ${terminal.id}`); + resumeTerminalClaudeSession(terminal.id); + } catch (error) { + // Log error and continue processing remaining terminals + debugError(`[TerminalStore] Error resuming terminal ${terminal.id}:`, error); + } + + // Wait before processing next terminal (staggered delay) + if (i < pendingTerminals.length - 1) { + await new Promise(resolve => setTimeout(resolve, AUTO_RESUME_STAGGER_MS)); + // Re-check generation after await (may have been cancelled during stagger) + if (generation !== autoResumeGeneration) { + debugLog(`[TerminalStore] Generation mismatch after stagger in resumeAll — aborting`); + return; + } + } + } + + debugLog('[TerminalStore] Completed resuming all pending Claude sessions'); + } finally { + isResumingAll = false; + } }, getTerminal: (id: string) => { @@ -507,6 +657,7 @@ export async function restoreTerminalSessions(projectPath: string): Promise