auto-claude: 188-terminal-claude-sessions-require-manual-click-to-r (#1743)

* auto-claude: subtask-1-1 - Add terminal.claudeSessionId assignment in termina

* auto-claude: subtask-2-1 - Add resumeAllPendingClaude action to terminal-store

* auto-claude: subtask-3-1 - Add Resume All button to TerminalHeader.tsx with p

* auto-claude: subtask-5-1 - Add debug logging to Terminal.tsx useEffect to dia

* auto-claude: subtask-5-2 - Fix auto-resume race condition for active terminal

- Add hasAttemptedAutoResumeRef to track resume attempts and prevent duplicates
- Remove debug logging from investigation phase
- Add 100ms setTimeout to defer resume check, ensuring React state updates propagate
- Reset ref when terminal is no longer pending to allow future resumes
- Double-check conditions before resuming to handle state changes during timeout
- Follow existing pattern similar to pendingWorktreeConfigRef for race condition handling

* auto-claude: subtask-5-2 - Implement fix for auto-resume race condition based

Fix race condition preventing active terminal auto-resume on startup by moving
hasAttemptedAutoResumeRef.current = true into setTimeout callback.

This ensures:
- Ref only set when timeout actually fires (not before)
- Effect can retry if re-runs before timeout executes
- Prevents missed auto-resume when isActive and pendingClaudeResume update timing varies

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(terminal): correct i18n key path and clean up resume-all logic

Fix Resume All button showing raw translation key by using the correct
nested path `terminal:resume.resumeAllSessions`. Also remove misleading
await/try-catch on fire-and-forget IPC call and replace indexOf() in
loop with indexed for-loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(terminal): prevent resume race condition and optimize re-renders

Clear pendingClaudeResume flag before IPC call in resumeAllPendingClaude
to prevent the auto-resume effect from firing concurrently for the same
terminal. Use a derived Zustand selector returning a primitive count
instead of subscribing to the full terminals array, avoiding O(n²)
re-renders across all TerminalHeader instances.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-02-06 21:40:51 +01:00
committed by GitHub
parent 826583b826
commit f8499e965b
6 changed files with 103 additions and 5 deletions
@@ -231,6 +231,7 @@ export async function restoreTerminal(
if (options.resumeClaudeSession && storedIsClaudeMode) {
// Set Claude mode so it persists correctly across app restarts
// Without this, storedIsClaudeMode would be false on next restore
terminal.claudeSessionId = storedClaudeSessionId;
terminal.isClaudeMode = true;
// Mark terminal as having a pending Claude resume
// The actual resume will be triggered when the terminal becomes active
@@ -76,6 +76,9 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
// Track last sent PTY dimensions to prevent redundant resize calls
// This ensures terminal.resize() stays in sync with PTY dimensions
const lastPtyDimensionsRef = useRef<{ cols: number; rows: number } | null>(null);
// Track if auto-resume has been attempted to prevent duplicate resume calls
// This fixes the race condition where isActive and pendingClaudeResume update timing can miss the effect trigger
const hasAttemptedAutoResumeRef = useRef(false);
// Track when the last resize was sent to PTY for grace period logic
// This prevents false positive mismatch warnings during async resize acknowledgment
const lastResizeTimeRef = useRef<number>(0);
@@ -557,10 +560,41 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
// This ensures Claude sessions are only resumed when the user actually views the terminal,
// preventing all terminals from resuming simultaneously on app startup (which can crash the app)
useEffect(() => {
// Reset resume attempt tracking when terminal is no longer pending
if (!terminal?.pendingClaudeResume) {
hasAttemptedAutoResumeRef.current = false;
return;
}
// Only attempt auto-resume once, even if the effect runs multiple times
if (hasAttemptedAutoResumeRef.current) {
return;
}
// Check if both conditions are met for auto-resume
if (isActive && terminal?.pendingClaudeResume) {
// Clear the pending flag and trigger the actual resume
useTerminalStore.getState().setPendingClaudeResume(id, false);
window.electronAPI.activateDeferredClaudeResume(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
const timer = setTimeout(() => {
if (!isMountedRef.current) return;
// Mark that we've attempted resume INSIDE the callback to prevent duplicates
// This ensures we only mark as attempted if the timeout actually fires
// (prevents race condition where effect re-runs before timeout executes)
if (hasAttemptedAutoResumeRef.current) return;
hasAttemptedAutoResumeRef.current = true;
// Double-check conditions before resuming (state might have changed)
const currentTerminal = useTerminalStore.getState().terminals.find((t) => t.id === id);
if (currentTerminal?.pendingClaudeResume) {
// Clear the pending flag and trigger the actual resume
useTerminalStore.getState().setPendingClaudeResume(id, false);
window.electronAPI.activateDeferredClaudeResume(id);
}
}, 100); // Small delay to let React finish batched updates
return () => clearTimeout(timer);
}
}, [isActive, id, terminal?.pendingClaudeResume]);
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import type { Task, TerminalWorktreeConfig } from '../../../shared/types';
import type { TerminalStatus } from '../../stores/terminal-store';
import { useTerminalStore } from '../../stores/terminal-store';
import { Button } from '../ui/button';
import { cn } from '../../lib/utils';
import { STATUS_COLORS } from './types';
@@ -71,6 +72,13 @@ export function TerminalHeader({
const { t } = useTranslation(['terminal', 'common']);
const backlogTasks = tasks.filter((t) => t.status === 'backlog');
// Check if 2+ terminals have pending Claude resume
// Use a derived selector returning a primitive to avoid re-renders on unrelated terminal changes
const pendingResumeCount = useTerminalStore(
(state) => state.terminals.filter((t) => t.pendingClaudeResume === true).length
);
const showResumeAllButton = pendingResumeCount >= 2;
return (
<div className="electron-no-drag group/header flex h-9 items-center justify-between border-b border-border/50 bg-card/30 px-2">
<div className="flex items-center gap-2">
@@ -153,6 +161,26 @@ export function TerminalHeader({
)}
</div>
<div className="flex items-center gap-1">
{/* Resume All button - shown when 2+ terminals have pending resume */}
{showResumeAllButton && (
<Button
variant="ghost"
size={terminalCount >= 4 ? 'icon' : 'sm'}
className={cn(
'h-6 hover:bg-cyan-500/10 hover:text-cyan-500 animate-pulse',
terminalCount >= 4 ? 'w-6' : 'px-2 text-xs gap-1',
'text-cyan-500 bg-cyan-500/10'
)}
onClick={(e) => {
e.stopPropagation();
useTerminalStore.getState().resumeAllPendingClaude();
}}
title={t('terminal:resume.resumeAllSessions')}
>
<RotateCcw className="h-3 w-3" />
{terminalCount < 4 && <span>{t('terminal:resume.resumeAllSessions')}</span>}
</Button>
)}
{/* Open in IDE button when worktree exists */}
{worktreeConfig && onOpenInIDE && (
<Button
@@ -131,6 +131,7 @@ interface TerminalState {
clearAllTerminals: () => void;
setHasRestoredSessions: (value: boolean) => void;
reorderTerminals: (activeId: string, overId: string) => void;
resumeAllPendingClaude: () => Promise<void>;
// Selectors
getTerminal: (id: string) => Terminal | undefined;
@@ -432,6 +433,38 @@ 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');
return;
}
debugLog(`[TerminalStore] Resuming ${pendingTerminals.length} pending Claude sessions with 500ms stagger`);
// 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);
debugLog(`[TerminalStore] Activating deferred Claude resume for terminal: ${terminal.id}`);
window.electronAPI.activateDeferredClaudeResume(terminal.id);
// Wait 500ms before processing next terminal (staggered delay)
if (i < pendingTerminals.length - 1) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
debugLog('[TerminalStore] Completed resuming all pending Claude sessions');
},
getTerminal: (id: string) => {
return get().terminals.find((t) => t.id === id);
},
@@ -5,7 +5,8 @@
},
"resume": {
"pending": "Resume Available",
"pendingTooltip": "Click to resume previous Claude session"
"pendingTooltip": "Click to resume previous Claude session",
"resumeAllSessions": "Resume All"
},
"auth": {
"terminalTitle": "Auth: {{profileName}}",
@@ -5,7 +5,8 @@
},
"resume": {
"pending": "Reprise disponible",
"pendingTooltip": "Cliquez pour reprendre la session Claude précédente"
"pendingTooltip": "Cliquez pour reprendre la session Claude précédente",
"resumeAllSessions": "Reprendre Tout"
},
"auth": {
"terminalTitle": "Auth: {{profileName}}",