fix: gate startup checks strictly on first submission, remove grace period (issue #363)
As gnanam1990 pointed out, the 3s grace period still allows the failure mode: if a user pauses for a few seconds before typing, startup checks fire and recommendation dialogs steal focus. A grace period is still a timing mitigation, not a reliable fix. New approach: startup checks only run after the user has submitted their first message (submitCount > 0). No grace period, no timeout. This guarantees the prompt gets first interaction — no dialog can steal focus before the user has actually used the CLI. If the user never submits a message, startup checks never run. That's acceptable because with no user interaction there's no need for plugin installations or marketplace seeding.
This commit is contained in:
@@ -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<Record<number, PastedContent>>({});
|
||||
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);
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
if (!options.hasHadFirstSubmission) return false;
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user