diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 1ee59943..227b2caa 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -238,6 +238,7 @@ import { usePromptsFromClaudeInChrome } from 'src/hooks/usePromptsFromClaudeInCh import { getTipToShowOnSpinner, recordShownTip } from 'src/services/tips/tipScheduler.js'; import type { Theme } from 'src/utils/theme.js'; import { isPromptTypingSuppressionActive } from './replInputSuppression.js'; +import { shouldRunStartupChecks } from './replStartupGates.js'; import { checkAndDisableBypassPermissionsIfNeeded, checkAndDisableAutoModeIfNeeded, useKickOffCheckAndDisableBypassPermissionsIfNeeded, useKickOffCheckAndDisableAutoModeIfNeeded } from 'src/utils/permissions/bypassPermissionsKillswitch.js'; import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js'; import { SANDBOX_NETWORK_ACCESS_TOOL_NAME } from 'src/cli/structuredIO.js'; @@ -792,10 +793,8 @@ export function REPL({ // accepts, and only then is the REPL component mounted and this effect runs. // This ensures that plugin installations from repository and user settings only // happen after explicit user consent to trust the current working directory. - useEffect(() => { - if (isRemoteSession) return; - void performStartupChecks(setAppState); - }, [setAppState, isRemoteSession]); + // Deferring startup checks is handled below (after promptTypingSuppressionActive + // is declared) to avoid temporal dead zone issues. // Allow Claude in Chrome MCP to send prompts through MCP notifications // and sync permission mode changes to the Chrome extension @@ -1429,6 +1428,25 @@ export function REPL({ const activeRemote = sshRemote.isRemoteMode ? sshRemote : directConnect.isRemoteMode ? directConnect : remoteSession; const [pastedContents, setPastedContents] = useState>({}); const [submitCount, setSubmitCount] = useState(0); + + // Defer startup checks until the user has submitted their first message. + // A timeout or grace period is insufficient (issue #363): if the user pauses + // before typing, startup checks can still fire and recommendation dialogs + // steal focus. Only the user's first submission guarantees the prompt was + // the first thing they interacted with. + const startupChecksStartedRef = React.useRef(false); + const hasHadFirstSubmission = (submitCount ?? 0) > 0; + useEffect(() => { + if (isRemoteSession) return; + if (startupChecksStartedRef.current) return; + if (!shouldRunStartupChecks({ + isRemoteSession, + hasStarted: startupChecksStartedRef.current, + hasHadFirstSubmission, + })) return; + startupChecksStartedRef.current = true; + void performStartupChecks(setAppState); + }, [setAppState, isRemoteSession, hasHadFirstSubmission]); // Ref instead of state to avoid triggering React re-renders on every // streaming text_delta. The spinner reads this via its animation timer. const responseLengthRef = useRef(0); @@ -2061,13 +2079,14 @@ export function REPL({ if (allowDialogsWithAnimation && showRemoteCallout) return 'remote-callout'; // LSP plugin recommendation (lowest priority - non-blocking suggestion) - if (allowDialogsWithAnimation && lspRecommendation) return 'lsp-recommendation'; + // Suppress during startup window to prevent stealing focus from the prompt (issue #363) + if (allowDialogsWithAnimation && lspRecommendation && startupChecksStartedRef.current) return 'lsp-recommendation'; // Plugin hint from CLI/SDK stderr (same priority band as LSP rec) - if (allowDialogsWithAnimation && hintRecommendation) return 'plugin-hint'; + if (allowDialogsWithAnimation && hintRecommendation && startupChecksStartedRef.current) return 'plugin-hint'; // Desktop app upsell (max 3 launches, lowest priority) - if (allowDialogsWithAnimation && showDesktopUpsellStartup) return 'desktop-upsell'; + if (allowDialogsWithAnimation && showDesktopUpsellStartup && startupChecksStartedRef.current) return 'desktop-upsell'; return undefined; } const focusedInputDialog = getFocusedInputDialog(); diff --git a/src/screens/replStartupGates.test.ts b/src/screens/replStartupGates.test.ts new file mode 100644 index 00000000..456cb56e --- /dev/null +++ b/src/screens/replStartupGates.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test' + +import { shouldRunStartupChecks } from './replStartupGates.js' + +describe('shouldRunStartupChecks', () => { + test('runs checks after first message submission', () => { + expect(shouldRunStartupChecks({ + isRemoteSession: false, + hasStarted: false, + hasHadFirstSubmission: true, + })).toBe(true) + }) + + test('skips checks in remote sessions even after submission', () => { + expect(shouldRunStartupChecks({ + isRemoteSession: true, + hasStarted: false, + hasHadFirstSubmission: true, + })).toBe(false) + }) + + test('skips checks if already started', () => { + expect(shouldRunStartupChecks({ + isRemoteSession: false, + hasStarted: true, + hasHadFirstSubmission: true, + })).toBe(false) + }) + + test('does not run checks before first submission', () => { + expect(shouldRunStartupChecks({ + isRemoteSession: false, + hasStarted: false, + hasHadFirstSubmission: false, + })).toBe(false) + }) + + test('does not run checks when idle before first submission', () => { + expect(shouldRunStartupChecks({ + isRemoteSession: false, + hasStarted: false, + hasHadFirstSubmission: false, + })).toBe(false) + }) + + test('skips checks in remote session regardless of other conditions', () => { + expect(shouldRunStartupChecks({ + isRemoteSession: true, + hasStarted: false, + hasHadFirstSubmission: false, + })).toBe(false) + }) +}) \ No newline at end of file diff --git a/src/screens/replStartupGates.ts b/src/screens/replStartupGates.ts new file mode 100644 index 00000000..88021c93 --- /dev/null +++ b/src/screens/replStartupGates.ts @@ -0,0 +1,35 @@ +/** + * Startup gates for the REPL. + * + * Prevents startup plugin checks and recommendation dialogs from stealing + * focus before the user has interacted with the prompt. + * + * This addresses the root cause of issue #363: on mount, performStartupChecks + * triggers plugin loading, which populates trackedFiles, which triggers + * useLspPluginRecommendation to surface an LSP recommendation dialog. Since + * promptTypingSuppressionActive is false before the user has typed anything, + * getFocusedInputDialog() returns the dialog, unmounting PromptInput entirely. + * + * The fix gates startup checks on actual prompt interaction. A pure timeout + * or grace period is insufficient because pausing before typing would still + * allow dialogs to steal focus. Only the user's first submission guarantees + * the prompt is no longer in the vulnerable pre-interaction window. + */ + +/** + * Determines whether startup checks should run. + * + * Startup checks are deferred until the user has submitted their first + * message. This guarantees the prompt was the first thing the user interacted + * with, so no recommendation dialog can steal focus before the first keystroke. + */ +export function shouldRunStartupChecks(options: { + isRemoteSession: boolean; + hasStarted: boolean; + hasHadFirstSubmission: boolean; +}): boolean { + if (options.isRemoteSession) return false; + if (options.hasStarted) return false; + if (!options.hasHadFirstSubmission) return false; + return true; +} \ No newline at end of file