diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 4a160d24..227b2caa 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -238,7 +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, STARTUP_GRACE_PERIOD_MS } from './replStartupGates.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'; @@ -1429,19 +1429,12 @@ export function REPL({ const [pastedContents, setPastedContents] = useState>({}); const [submitCount, setSubmitCount] = useState(0); - // Defer startup checks until the user has interacted with the prompt. - // A pure timeout is insufficient (issue #363): if the user pauses >1.5s - // before typing, the timer can still fire and recommendation dialogs can - // steal focus. Instead, we gate on actual prompt readiness: - // - First message submitted (submitCount > 0) - // - Grace period elapsed + user is not actively typing - // - User is typing (deferred until they stop) + // 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 [startupGraceElapsed, setStartupGraceElapsed] = useState(false); - useEffect(() => { - const timer = setTimeout(() => setStartupGraceElapsed(true), STARTUP_GRACE_PERIOD_MS); - return () => clearTimeout(timer); - }, []); const hasHadFirstSubmission = (submitCount ?? 0) > 0; useEffect(() => { if (isRemoteSession) return; @@ -1449,13 +1442,11 @@ export function REPL({ if (!shouldRunStartupChecks({ isRemoteSession, hasStarted: startupChecksStartedRef.current, - promptTypingSuppressionActive, hasHadFirstSubmission, - gracePeriodElapsed: startupGraceElapsed, })) return; startupChecksStartedRef.current = true; void performStartupChecks(setAppState); - }, [setAppState, isRemoteSession, promptTypingSuppressionActive, hasHadFirstSubmission, startupGraceElapsed]); + }, [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); diff --git a/src/screens/replStartupGates.test.ts b/src/screens/replStartupGates.test.ts index 72c3aee6..456cb56e 100644 --- a/src/screens/replStartupGates.test.ts +++ b/src/screens/replStartupGates.test.ts @@ -1,25 +1,21 @@ import { describe, expect, test } from 'bun:test' -import { shouldRunStartupChecks, STARTUP_GRACE_PERIOD_MS } from './replStartupGates.js' +import { shouldRunStartupChecks } from './replStartupGates.js' describe('shouldRunStartupChecks', () => { - test('runs checks after first message submission regardless of grace period', () => { + test('runs checks after first message submission', () => { expect(shouldRunStartupChecks({ isRemoteSession: false, hasStarted: false, - promptTypingSuppressionActive: false, hasHadFirstSubmission: true, - gracePeriodElapsed: false, })).toBe(true) }) - test('skips checks in remote sessions', () => { + test('skips checks in remote sessions even after submission', () => { expect(shouldRunStartupChecks({ isRemoteSession: true, hasStarted: false, - promptTypingSuppressionActive: false, - hasHadFirstSubmission: false, - gracePeriodElapsed: true, + hasHadFirstSubmission: true, })).toBe(false) }) @@ -27,66 +23,31 @@ describe('shouldRunStartupChecks', () => { expect(shouldRunStartupChecks({ isRemoteSession: false, hasStarted: true, - promptTypingSuppressionActive: false, - hasHadFirstSubmission: false, - gracePeriodElapsed: true, - })).toBe(false) - }) - - test('does not run checks before grace period when user is idle', () => { - expect(shouldRunStartupChecks({ - isRemoteSession: false, - hasStarted: false, - promptTypingSuppressionActive: false, - hasHadFirstSubmission: false, - gracePeriodElapsed: false, - })).toBe(false) - }) - - test('runs checks after grace period when user is idle', () => { - expect(shouldRunStartupChecks({ - isRemoteSession: false, - hasStarted: false, - promptTypingSuppressionActive: false, - hasHadFirstSubmission: false, - gracePeriodElapsed: true, - })).toBe(true) - }) - - test('does not run checks while user is actively typing even after grace period', () => { - expect(shouldRunStartupChecks({ - isRemoteSession: false, - hasStarted: false, - promptTypingSuppressionActive: true, - hasHadFirstSubmission: false, - gracePeriodElapsed: true, - })).toBe(false) - }) - - test('runs checks after first submission even while typing', () => { - expect(shouldRunStartupChecks({ - isRemoteSession: false, - hasStarted: false, - promptTypingSuppressionActive: true, hasHadFirstSubmission: true, - gracePeriodElapsed: false, - })).toBe(true) + })).toBe(false) }) - test('does not run checks before grace period even with typing suppression', () => { + test('does not run checks before first submission', () => { expect(shouldRunStartupChecks({ isRemoteSession: false, hasStarted: false, - promptTypingSuppressionActive: true, hasHadFirstSubmission: false, - gracePeriodElapsed: false, })).toBe(false) }) -}) -describe('STARTUP_GRACE_PERIOD_MS', () => { - test('grace period is positive and reasonable', () => { - expect(STARTUP_GRACE_PERIOD_MS).toBeGreaterThan(0) - expect(STARTUP_GRACE_PERIOD_MS).toBeLessThanOrEqual(10000) + 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 index 179cda9d..88021c93 100644 --- a/src/screens/replStartupGates.ts +++ b/src/screens/replStartupGates.ts @@ -10,45 +10,26 @@ * promptTypingSuppressionActive is false before the user has typed anything, * getFocusedInputDialog() returns the dialog, unmounting PromptInput entirely. * - * The fix gates startup checks on actual prompt readiness — either the user - * has started typing (inputValue is non-empty) or has submitted their first - * message. A pure timeout is insufficient because pausing for >1.5s before - * typing would still allow dialogs to steal focus. + * 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. */ -const STARTUP_GRACE_PERIOD_MS = 3000 - /** * Determines whether startup checks should run. * - * Startup checks are deferred until one of: - * 1. The user has typed something into the prompt (inputValue non-empty) - * 2. The user has submitted their first message (hasHadFirstSubmission) - * 3. The grace period has elapsed AND the user is not actively typing - * (fallback for long idle periods where checks should eventually run, - * but only when it won't interrupt an active typing session) + * 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; - promptTypingSuppressionActive: boolean; hasHadFirstSubmission: boolean; - gracePeriodElapsed: boolean; }): boolean { if (options.isRemoteSession) return false; if (options.hasStarted) return false; - - // User has submitted their first message — safe to run checks - if (options.hasHadFirstSubmission) return true; - - // User has typed something and grace period has passed — safe once they stop - if (options.promptTypingSuppressionActive && options.gracePeriodElapsed) return false; - - // Grace period elapsed and user is idle — safe to run checks - if (options.gracePeriodElapsed && !options.promptTypingSuppressionActive) return true; - - // Before grace period — don't run checks yet - return false; -} - -export { STARTUP_GRACE_PERIOD_MS } \ No newline at end of file + if (!options.hasHadFirstSubmission) return false; + return true; +} \ No newline at end of file