From f8499e965b86700f4db41f594ca91b9ffa047dbb Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:40:51 +0100 Subject: [PATCH] auto-claude: 188-terminal-claude-sessions-require-manual-click-to-r (#1743) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 --------- Co-authored-by: Claude Sonnet 4.5 --- .../src/main/terminal/terminal-lifecycle.ts | 1 + .../src/renderer/components/Terminal.tsx | 40 +++++++++++++++++-- .../components/terminal/TerminalHeader.tsx | 28 +++++++++++++ .../src/renderer/stores/terminal-store.ts | 33 +++++++++++++++ .../src/shared/i18n/locales/en/terminal.json | 3 +- .../src/shared/i18n/locales/fr/terminal.json | 3 +- 6 files changed, 103 insertions(+), 5 deletions(-) diff --git a/apps/frontend/src/main/terminal/terminal-lifecycle.ts b/apps/frontend/src/main/terminal/terminal-lifecycle.ts index 6340885a..0105f6ae 100644 --- a/apps/frontend/src/main/terminal/terminal-lifecycle.ts +++ b/apps/frontend/src/main/terminal/terminal-lifecycle.ts @@ -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 diff --git a/apps/frontend/src/renderer/components/Terminal.tsx b/apps/frontend/src/renderer/components/Terminal.tsx index fdb80c2f..463b8f5d 100644 --- a/apps/frontend/src/renderer/components/Terminal.tsx +++ b/apps/frontend/src/renderer/components/Terminal.tsx @@ -76,6 +76,9 @@ export const Terminal = forwardRef(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(0); @@ -557,10 +560,41 @@ export const Terminal = forwardRef(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]); diff --git a/apps/frontend/src/renderer/components/terminal/TerminalHeader.tsx b/apps/frontend/src/renderer/components/terminal/TerminalHeader.tsx index ad6be554..91b43e0b 100644 --- a/apps/frontend/src/renderer/components/terminal/TerminalHeader.tsx +++ b/apps/frontend/src/renderer/components/terminal/TerminalHeader.tsx @@ -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 (
@@ -153,6 +161,26 @@ export function TerminalHeader({ )}
+ {/* Resume All button - shown when 2+ terminals have pending resume */} + {showResumeAllButton && ( + + )} {/* Open in IDE button when worktree exists */} {worktreeConfig && onOpenInIDE && (