Skip to content
16 changes: 15 additions & 1 deletion apps/frontend/src/renderer/components/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -381,6 +381,17 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(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
Expand Down Expand Up @@ -573,6 +584,8 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(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
Expand Down Expand Up @@ -630,6 +643,7 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi

return () => {
isMountedRef.current = false;
dequeueAutoResume(id);

This comment was marked as outdated.

cleanupAutoNaming();

// Clear post-creation dimension check timeout to prevent operations on unmounted component
Expand Down
155 changes: 134 additions & 21 deletions apps/frontend/src/renderer/stores/terminal-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,107 @@ 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[] = [];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For managing a collection of unique terminal IDs, using a Set would be more idiomatic and slightly more performant than an array. A Set provides O(1) average time complexity for add, delete, and has operations, compared to O(n) for Array.prototype.includes and Array.prototype.splice.

While the performance difference is negligible with a maximum of 12 terminals, using a Set better expresses the intent that the queue contains unique items and simplifies the implementation.

To implement this, you would change this line and update the related functions:

  1. Declaration: let autoResumeQueue = new Set<string>();
  2. enqueueAutoResume: Replace autoResumeQueue.includes(terminalId) with autoResumeQueue.has(terminalId) and autoResumeQueue.push(terminalId) with autoResumeQueue.add(terminalId).
  3. dequeueAutoResume: The body can be simplified to if (autoResumeQueue.delete(terminalId)) { ... }.
  4. clearAutoResumeQueue: Use autoResumeQueue.clear() instead of autoResumeQueue = [].
  5. processAutoResumeQueue: The loop condition becomes while (autoResumeQueue.size > 0). To get and remove an item, you can do: const terminalId = autoResumeQueue.values().next().value; autoResumeQueue.delete(terminalId);.
Suggested change
let autoResumeQueue: string[] = [];
let autoResumeQueue = new Set<string>();

let autoResumeTimer: ReturnType<typeof setTimeout> | 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;
}
autoResumeProcessing = false;
autoResumeGeneration++; // Increment generation to abort any in-flight processing
debugLog('[AutoResume] Queue cleared');
Comment on lines 118 to 128

This comment was marked as outdated.

}

/**
* 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<void> {
if (autoResumeProcessing) return;
autoResumeProcessing = true;
const generation = autoResumeGeneration; // Capture generation to detect cancellation
debugLog(`[AutoResume] Processing queue (generation ${generation}), ${autoResumeQueue.length} terminals`);

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; // Don't reset autoResumeProcessing — the new run owns it
}

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;
}

debugLog(`[AutoResume] Resuming terminal: ${terminalId}`);
resumeTerminalClaudeSession(terminalId);

// 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;
}
}
}

// Only reset processing flag if this is still the current generation
if (generation === autoResumeGeneration) {
autoResumeProcessing = false;
}
debugLog('[AutoResume] Queue processing complete');
}

export type TerminalStatus = 'idle' | 'running' | 'claude-active' | 'exited';

export interface Terminal {
Expand Down Expand Up @@ -434,35 +535,46 @@ export const useTerminalStore = create<TerminalState>((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;

debugLog(`[TerminalStore] Resuming ${pendingTerminals.length} pending Claude sessions with 500ms stagger`);
try {
// Clear auto-resume queue to prevent redundant processing
clearAutoResumeQueue();
Comment on lines +557 to +562
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The resumeAllPendingClaude function captures autoResumeGeneration before it's incremented, causing the function to exit immediately without resuming any terminals.
Severity: HIGH

Suggested Fix

Move the line const generation = autoResumeGeneration; to after the clearAutoResumeQueue() call. This ensures the captured generation value is the new, post-increment value, preventing the function from immediately invalidating its own operation.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: apps/frontend/src/renderer/stores/terminal-store.ts#L557-L562

Potential issue: The `resumeAllPendingClaude` function, triggered by the user-facing
"Resume All" button, contains a logic error that prevents it from working. It captures
the value of `autoResumeGeneration` before calling `clearAutoResumeQueue()`, which
increments the same counter. Consequently, a subsequent check `if (generation !==
autoResumeGeneration)` is immediately true, causing the function to abort before any
terminals are resumed. This results in the "Resume All" feature failing silently without
performing its intended action.


// 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`);

// Iterate through terminals with staggered delays
for (let i = 0; i < pendingTerminals.length; i++) {
const terminal = pendingTerminals[i];

debugLog(`[TerminalStore] Activating deferred Claude resume for terminal: ${terminal.id}`);
resumeTerminalClaudeSession(terminal.id);

// Wait before processing next terminal (staggered delay)
if (i < pendingTerminals.length - 1) {
await new Promise(resolve => setTimeout(resolve, AUTO_RESUME_STAGGER_MS));
}
}

debugLog('[TerminalStore] Completed resuming all pending Claude sessions');
} finally {
isResumingAll = false;
}
},
Comment on lines 548 to 616
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

resumeAllPendingClaude lacks a cancellation mechanism, unlike processAutoResumeQueue.

If a project switch triggers restoreTerminalSessionsclearAutoResumeQueue() while resumeAllPendingClaude is mid-stagger, this function continues iterating through its stale snapshot. The per-item re-check (line 577) prevents duplicate IPC calls, but the function still holds isResumingAll = true and burns through stagger delays for up to n × 500ms unnecessarily. During that window a second resumeAllPendingClaude call (for the new project) would be silently dropped by the guard on line 550.

Consider reusing the generation counter (or a dedicated one) to allow early exit, matching the pattern in processAutoResumeQueue.

Sketch: add generation-based cancellation
  resumeAllPendingClaude: async () => {
     if (isResumingAll) {
       debugLog('[TerminalStore] Resume All already in progress — skipping');
       return;
     }
     isResumingAll = true;
+    const gen = autoResumeGeneration; // capture before clearAutoResumeQueue bumps it

     try {
       clearAutoResumeQueue();
       // ...
       for (let i = 0; i < pendingTerminals.length; i++) {
+        if (gen !== autoResumeGeneration) {
+          debugLog('[TerminalStore] Generation mismatch in resumeAll — aborting');
+          return;
+        }
         // ... existing re-check + resume + stagger ...
       }
     } finally {
       isResumingAll = false;
     }
   },

Note: capture gen before clearAutoResumeQueue() so the clear inside the same call doesn't immediately invalidate itself, or use a separate counter for resumeAllPendingClaude.

🤖 Prompt for AI Agents
In `@apps/frontend/src/renderer/stores/terminal-store.ts` around lines 548 - 601,
resumeAllPendingClaude currently has no cancellation token so it can keep
waiting through stagger delays even after
clearAutoResumeQueue/restoreTerminalSessions runs; capture a generation counter
(or use an existing one like the one used by processAutoResumeQueue) before
calling clearAutoResumeQueue and store it in a local const gen, then inside the
loop re-check that the global generation still equals gen (or check a dedicated
isCancelled flag) before each iteration and before awaiting the stagger delay
and break early if it changed; ensure you reference and update the same
generation variable used elsewhere (or introduce a new one) and still reset
isResumingAll = false in the finally block, while keeping calls to
resumeTerminalClaudeSession and debug logs unchanged.


getTerminal: (id: string) => {
Expand Down Expand Up @@ -507,6 +619,7 @@ export async function restoreTerminalSessions(projectPath: string): Promise<void
return;
}
restoringProjects.add(projectPath);
clearAutoResumeQueue();

try {
const store = useTerminalStore.getState();
Expand Down
Loading