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:
@@ -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}}",
|
||||
|
||||
Reference in New Issue
Block a user