Compare commits
5 Commits
main
...
fix/363-st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af5bb8fed8 | ||
|
|
ad76b1174a | ||
|
|
c457d9db3c | ||
|
|
d1f79088a1 | ||
|
|
106f85d0bf |
@@ -238,6 +238,7 @@ import { usePromptsFromClaudeInChrome } from 'src/hooks/usePromptsFromClaudeInCh
|
|||||||
import { getTipToShowOnSpinner, recordShownTip } from 'src/services/tips/tipScheduler.js';
|
import { getTipToShowOnSpinner, recordShownTip } from 'src/services/tips/tipScheduler.js';
|
||||||
import type { Theme } from 'src/utils/theme.js';
|
import type { Theme } from 'src/utils/theme.js';
|
||||||
import { isPromptTypingSuppressionActive } from './replInputSuppression.js';
|
import { isPromptTypingSuppressionActive } from './replInputSuppression.js';
|
||||||
|
import { shouldRunStartupChecks } from './replStartupGates.js';
|
||||||
import { checkAndDisableBypassPermissionsIfNeeded, checkAndDisableAutoModeIfNeeded, useKickOffCheckAndDisableBypassPermissionsIfNeeded, useKickOffCheckAndDisableAutoModeIfNeeded } from 'src/utils/permissions/bypassPermissionsKillswitch.js';
|
import { checkAndDisableBypassPermissionsIfNeeded, checkAndDisableAutoModeIfNeeded, useKickOffCheckAndDisableBypassPermissionsIfNeeded, useKickOffCheckAndDisableAutoModeIfNeeded } from 'src/utils/permissions/bypassPermissionsKillswitch.js';
|
||||||
import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js';
|
import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js';
|
||||||
import { SANDBOX_NETWORK_ACCESS_TOOL_NAME } from 'src/cli/structuredIO.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.
|
// accepts, and only then is the REPL component mounted and this effect runs.
|
||||||
// This ensures that plugin installations from repository and user settings only
|
// This ensures that plugin installations from repository and user settings only
|
||||||
// happen after explicit user consent to trust the current working directory.
|
// happen after explicit user consent to trust the current working directory.
|
||||||
useEffect(() => {
|
// Deferring startup checks is handled below (after promptTypingSuppressionActive
|
||||||
if (isRemoteSession) return;
|
// is declared) to avoid temporal dead zone issues.
|
||||||
void performStartupChecks(setAppState);
|
|
||||||
}, [setAppState, isRemoteSession]);
|
|
||||||
|
|
||||||
// Allow Claude in Chrome MCP to send prompts through MCP notifications
|
// Allow Claude in Chrome MCP to send prompts through MCP notifications
|
||||||
// and sync permission mode changes to the Chrome extension
|
// 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 activeRemote = sshRemote.isRemoteMode ? sshRemote : directConnect.isRemoteMode ? directConnect : remoteSession;
|
||||||
const [pastedContents, setPastedContents] = useState<Record<number, PastedContent>>({});
|
const [pastedContents, setPastedContents] = useState<Record<number, PastedContent>>({});
|
||||||
const [submitCount, setSubmitCount] = useState(0);
|
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
|
// Ref instead of state to avoid triggering React re-renders on every
|
||||||
// streaming text_delta. The spinner reads this via its animation timer.
|
// streaming text_delta. The spinner reads this via its animation timer.
|
||||||
const responseLengthRef = useRef(0);
|
const responseLengthRef = useRef(0);
|
||||||
@@ -2061,13 +2079,14 @@ export function REPL({
|
|||||||
if (allowDialogsWithAnimation && showRemoteCallout) return 'remote-callout';
|
if (allowDialogsWithAnimation && showRemoteCallout) return 'remote-callout';
|
||||||
|
|
||||||
// LSP plugin recommendation (lowest priority - non-blocking suggestion)
|
// 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)
|
// 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)
|
// Desktop app upsell (max 3 launches, lowest priority)
|
||||||
if (allowDialogsWithAnimation && showDesktopUpsellStartup) return 'desktop-upsell';
|
if (allowDialogsWithAnimation && showDesktopUpsellStartup && startupChecksStartedRef.current) return 'desktop-upsell';
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const focusedInputDialog = getFocusedInputDialog();
|
const focusedInputDialog = getFocusedInputDialog();
|
||||||
|
|||||||
53
src/screens/replStartupGates.test.ts
Normal file
53
src/screens/replStartupGates.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
35
src/screens/replStartupGates.ts
Normal file
35
src/screens/replStartupGates.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user