Skip to content
13 changes: 12 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,14 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
}
pendingWorktreeConfigRef.current = null;
}
// Auto-resume: enqueue non-active terminals for staggered resume
if (!isActive && !hasAttemptedAutoResumeRef.current) {
const currentTerminal = useTerminalStore.getState().terminals.find(t => t.id === id);
if (currentTerminal?.pendingClaudeResume) {
hasAttemptedAutoResumeRef.current = true;
enqueueAutoResume(id);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

high

There is a potential bug here. Setting hasAttemptedAutoResumeRef.current = true when a non-active terminal is enqueued prevents it from being resumed immediately if it becomes active before the queue processor handles it. The useEffect that handles active terminal resumes will see hasAttemptedAutoResumeRef.current as true and skip the resume logic, leading to a poor user experience.

The hasAttemptedAutoResumeRef should only be set when a resume is actually triggered, not when it's just enqueued. The enqueueAutoResume function already prevents duplicate entries in the queue, and the onCreated callback only runs once per PTY creation, so there's no risk of multiple enqueues from here.

I suggest removing the check for hasAttemptedAutoResumeRef and the line that sets it.

      if (!isActive) {
        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 +581,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 +640,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
75 changes: 75 additions & 0 deletions apps/frontend/src/renderer/stores/terminal-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,80 @@ 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;

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;

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;
debugLog('[AutoResume] Queue cleared');
Comment on lines 118 to 128

This comment was marked as outdated.

}

async function processAutoResumeQueue(): Promise<void> {
if (autoResumeProcessing) return;
autoResumeProcessing = true;
debugLog(`[AutoResume] Processing queue, ${autoResumeQueue.length} terminals`);

while (autoResumeQueue.length > 0) {
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}`);
useTerminalStore.getState().setPendingClaudeResume(terminalId, false);
window.electronAPI.activateDeferredClaudeResume(terminalId);

// Stagger delay between resumes
if (autoResumeQueue.length > 0) {
await new Promise(resolve => setTimeout(resolve, AUTO_RESUME_STAGGER_MS));
}
}

autoResumeProcessing = false;
debugLog('[AutoResume] Queue processing complete');
}

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

export interface Terminal {
Expand Down Expand Up @@ -507,6 +581,7 @@ export async function restoreTerminalSessions(projectPath: string): Promise<void
return;
}
restoringProjects.add(projectPath);
clearAutoResumeQueue();

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