diff --git a/src/core/mentions/__tests__/index.test.ts b/src/core/mentions/__tests__/index.test.ts index d9399bb47d..be75aad1b8 100644 --- a/src/core/mentions/__tests__/index.test.ts +++ b/src/core/mentions/__tests__/index.test.ts @@ -99,6 +99,17 @@ jest.mock("../../../integrations/misc/open-file", () => ({ })) import { openFile } from "../../../integrations/misc/open-file" +jest.mock("../../webview/ClineProvider", () => ({ + ClineProvider: { + getVisibleInstance: jest.fn().mockReturnValue({ + getCurrentCline: jest.fn().mockReturnValue(null), + contextProxy: { + getValue: jest.fn().mockReturnValue(false), + }, + }), + }, +})) + jest.mock("../../../integrations/misc/extract-text", () => ({ extractTextFromFile: jest.fn(), })) @@ -256,7 +267,7 @@ Detailed commit message with multiple lines await openMention(mention) - expect(openFile).toHaveBeenCalledWith(expectedAbsPath) + expect(openFile).toHaveBeenCalledWith(expectedAbsPath, { preserveFocus: false }) expect(vscode.commands.executeCommand).not.toHaveBeenCalled() }) diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts index 8ae4f7f131..21272c4007 100644 --- a/src/core/mentions/index.ts +++ b/src/core/mentions/index.ts @@ -10,6 +10,7 @@ import { getCommitInfo, getWorkingState } from "../../utils/git" import { getWorkspacePath } from "../../utils/path" import { openFile } from "../../integrations/misc/open-file" +import { isInAutomatedWorkflowFromVisibleProvider } from "../../utils/workflow-detection" import { extractTextFromFile } from "../../integrations/misc/extract-text" import { diagnosticsToProblemsString } from "../../integrations/diagnostics" @@ -36,7 +37,12 @@ export async function openMention(mention?: string): Promise { if (mention.endsWith("/")) { vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(absPath)) } else { - openFile(absPath) + // Check if we're in an automated workflow to preserve chat focus during AI processing. + // When users mention files (e.g., @/path/to/file.txt) while the AI is actively processing, + // we want to prevent the chatbox from losing focus to avoid accidental input interruption. + const shouldPreserveFocus = isInAutomatedWorkflowFromVisibleProvider() + + openFile(absPath, { preserveFocus: shouldPreserveFocus }) } } else if (mention === "problems") { vscode.commands.executeCommand("workbench.actions.view.problems") diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 6568b4aaee..0a4d33f5b3 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -39,6 +39,7 @@ import { getModels, flushModels } from "../../api/providers/fetchers/modelCache" import { GetModelsOptions } from "../../shared/api" import { generateSystemPrompt } from "./generateSystemPrompt" import { getCommand } from "../../utils/commands" +import { isInAutomatedWorkflowFromProvider } from "../../utils/workflow-detection" const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) @@ -426,7 +427,16 @@ export const webviewMessageHandler = async ( openImage(message.text!) break case "openFile": - openFile(message.text!, message.values as { create?: boolean; content?: string; line?: number }) + // Check if we're in an automated workflow to preserve chat focus during AI processing. + // This prevents the chatbox from losing focus when the AI is actively working, + // which could cause users to accidentally type sensitive information (like API keys) + // into the wrong window. See issue #4574 for more context. + const shouldPreserveFocus = isInAutomatedWorkflowFromProvider(provider) + + openFile(message.text!, { + ...(message.values as { create?: boolean; content?: string; line?: number }), + preserveFocus: shouldPreserveFocus, + }) break case "openMention": openMention(message.text) @@ -481,6 +491,7 @@ export const webviewMessageHandler = async ( const customModesFilePath = await provider.customModesManager.getCustomModesFilePath() if (customModesFilePath) { + // User-initiated settings opening, keep focus openFile(customModesFilePath) } @@ -490,6 +501,7 @@ export const webviewMessageHandler = async ( const mcpSettingsFilePath = await provider.getMcpHub()?.getMcpSettingsFilePath() if (mcpSettingsFilePath) { + // User-initiated settings opening, keep focus openFile(mcpSettingsFilePath) } @@ -513,6 +525,7 @@ export const webviewMessageHandler = async ( await fs.writeFile(mcpPath, JSON.stringify({ mcpServers: {} }, null, 2)) } + // User-initiated settings opening, keep focus await openFile(mcpPath) } catch (error) { vscode.window.showErrorMessage(t("mcp:errors.create_json", { error: `${error}` })) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 3ab0419618..2f6554c54d 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -11,6 +11,7 @@ import { formatResponse } from "../../core/prompts/responses" import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" import { ClineSayTool } from "../../shared/ExtensionMessage" import { Task } from "../../core/task/Task" +import { isInAutomatedWorkflowFromVisibleProvider } from "../../utils/workflow-detection" import { DecorationController } from "./DecorationController" @@ -464,10 +465,17 @@ export class DiffViewProvider { if (this.activeDiffEditor) { const scrollLine = line + 4 - this.activeDiffEditor.revealRange( - new vscode.Range(scrollLine, 0, scrollLine, 0), - vscode.TextEditorRevealType.InCenter, - ) + // Check if we're in an automated workflow to preserve chat focus. + // When the AI is actively processing, we skip scrolling to prevent + // the diff view from stealing focus from the chat input. + const shouldPreserveFocus = isInAutomatedWorkflowFromVisibleProvider() + + if (!shouldPreserveFocus) { + this.activeDiffEditor.revealRange( + new vscode.Range(scrollLine, 0, scrollLine, 0), + vscode.TextEditorRevealType.InCenter, + ) + } } } @@ -481,13 +489,20 @@ export class DiffViewProvider { let lineCount = 0 + // Check if we're in an automated workflow to preserve chat focus. + // When the AI is actively processing, we skip scrolling to prevent + // the diff view from stealing focus from the chat input. + const shouldPreserveFocus = isInAutomatedWorkflowFromVisibleProvider() + for (const part of diffs) { if (part.added || part.removed) { - // Found the first diff, scroll to it. - this.activeDiffEditor.revealRange( - new vscode.Range(lineCount, 0, lineCount, 0), - vscode.TextEditorRevealType.InCenter, - ) + // Found the first diff, scroll to it only if not in automated workflow + if (!shouldPreserveFocus) { + this.activeDiffEditor.revealRange( + new vscode.Range(lineCount, 0, lineCount, 0), + vscode.TextEditorRevealType.InCenter, + ) + } return } diff --git a/src/integrations/misc/open-file.ts b/src/integrations/misc/open-file.ts index 9318e23766..30744958d4 100644 --- a/src/integrations/misc/open-file.ts +++ b/src/integrations/misc/open-file.ts @@ -24,6 +24,7 @@ interface OpenFileOptions { create?: boolean content?: string line?: number + preserveFocus?: boolean } export async function openFile(filePath: string, options: OpenFileOptions = {}) { @@ -148,6 +149,7 @@ export async function openFile(filePath: string, options: OpenFileOptions = {}) await vscode.window.showTextDocument(document, { preview: false, selection, + preserveFocus: options.preserveFocus, }) } catch (error) { if (error instanceof Error) { diff --git a/src/utils/__tests__/workflow-detection.test.ts b/src/utils/__tests__/workflow-detection.test.ts new file mode 100644 index 0000000000..2b17384940 --- /dev/null +++ b/src/utils/__tests__/workflow-detection.test.ts @@ -0,0 +1,322 @@ +// Mock ClineProvider first +const mockGetVisibleInstance = jest.fn() +jest.mock("../../core/webview/ClineProvider", () => ({ + ClineProvider: { + getVisibleInstance: mockGetVisibleInstance, + }, +})) + +import { + isInAutomatedWorkflow, + isInAutomatedWorkflowFromProvider, + isInAutomatedWorkflowFromVisibleProvider, +} from "../workflow-detection" +import type { Task } from "../../core/task/Task" +import type { ClineProvider } from "../../core/webview/ClineProvider" + +// Mock ClineProvider +const mockProvider = { + getCurrentCline: jest.fn(), + contextProxy: { + getValue: jest.fn(), + }, +} as unknown as ClineProvider + +const mockVisibleProvider = { + getCurrentCline: jest.fn(), + contextProxy: { + getValue: jest.fn(), + }, +} as unknown as ClineProvider + +describe("workflow-detection", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe("isInAutomatedWorkflow", () => { + it("should return false when currentTask is null", () => { + expect(isInAutomatedWorkflow(null, false)).toBe(false) + expect(isInAutomatedWorkflow(null, true)).toBe(false) + }) + + it("should return false when currentTask is undefined", () => { + expect(isInAutomatedWorkflow(undefined, false)).toBe(false) + expect(isInAutomatedWorkflow(undefined, true)).toBe(false) + }) + + it("should return true when task is streaming", () => { + const streamingTask = { + isStreaming: true, + isWaitingForFirstChunk: false, + didCompleteReadingStream: true, + presentAssistantMessageLocked: false, + } as Task + + expect(isInAutomatedWorkflow(streamingTask, false)).toBe(true) + expect(isInAutomatedWorkflow(streamingTask, true)).toBe(true) + }) + + it("should return true when task is waiting for first chunk", () => { + const waitingTask = { + isStreaming: false, + isWaitingForFirstChunk: true, + didCompleteReadingStream: true, + presentAssistantMessageLocked: false, + } as Task + + expect(isInAutomatedWorkflow(waitingTask, false)).toBe(true) + expect(isInAutomatedWorkflow(waitingTask, true)).toBe(true) + }) + + it("should return true when auto-approval is enabled and stream not completed", () => { + const incompleteStreamTask = { + isStreaming: false, + isWaitingForFirstChunk: false, + didCompleteReadingStream: false, + presentAssistantMessageLocked: false, + } as Task + + expect(isInAutomatedWorkflow(incompleteStreamTask, false)).toBe(false) + expect(isInAutomatedWorkflow(incompleteStreamTask, true)).toBe(true) + }) + + it("should return true when auto-approval is enabled and assistant message is locked", () => { + const lockedMessageTask = { + isStreaming: false, + isWaitingForFirstChunk: false, + didCompleteReadingStream: true, + presentAssistantMessageLocked: true, + } as Task + + expect(isInAutomatedWorkflow(lockedMessageTask, false)).toBe(false) + expect(isInAutomatedWorkflow(lockedMessageTask, true)).toBe(true) + }) + + it("should return false when task is idle and auto-approval is disabled", () => { + const idleTask = { + isStreaming: false, + isWaitingForFirstChunk: false, + didCompleteReadingStream: true, + presentAssistantMessageLocked: false, + } as Task + + expect(isInAutomatedWorkflow(idleTask, false)).toBe(false) + }) + + it("should return false when task is idle even with auto-approval enabled", () => { + const idleTask = { + isStreaming: false, + isWaitingForFirstChunk: false, + didCompleteReadingStream: true, + presentAssistantMessageLocked: false, + } as Task + + expect(isInAutomatedWorkflow(idleTask, true)).toBe(false) + }) + + it("should return true for multiple simultaneous conditions", () => { + const busyTask = { + isStreaming: true, + isWaitingForFirstChunk: true, + didCompleteReadingStream: false, + presentAssistantMessageLocked: true, + } as Task + + expect(isInAutomatedWorkflow(busyTask, false)).toBe(true) + expect(isInAutomatedWorkflow(busyTask, true)).toBe(true) + }) + + it("should handle tasks with missing properties gracefully", () => { + const partialTask = {} as Task + + expect(isInAutomatedWorkflow(partialTask, false)).toBe(false) + expect(isInAutomatedWorkflow(partialTask, true)).toBe(false) + }) + }) + + describe("isInAutomatedWorkflowFromProvider", () => { + it("should return false when no current task", () => { + mockProvider.getCurrentCline = jest.fn().mockReturnValue(null) + mockProvider.contextProxy.getValue = jest.fn().mockReturnValue(false) + + expect(isInAutomatedWorkflowFromProvider(mockProvider)).toBe(false) + }) + + it("should return true when task is streaming", () => { + const streamingTask = { + isStreaming: true, + isWaitingForFirstChunk: false, + didCompleteReadingStream: true, + presentAssistantMessageLocked: false, + } as Task + + mockProvider.getCurrentCline = jest.fn().mockReturnValue(streamingTask) + mockProvider.contextProxy.getValue = jest.fn().mockReturnValue(false) + + expect(isInAutomatedWorkflowFromProvider(mockProvider)).toBe(true) + }) + + it("should return true when auto-approval is enabled and conditions are met", () => { + const incompleteTask = { + isStreaming: false, + isWaitingForFirstChunk: false, + didCompleteReadingStream: false, + presentAssistantMessageLocked: false, + } as Task + + mockProvider.getCurrentCline = jest.fn().mockReturnValue(incompleteTask) + mockProvider.contextProxy.getValue = jest.fn().mockReturnValue(true) + + expect(isInAutomatedWorkflowFromProvider(mockProvider)).toBe(true) + }) + + it("should return false when task is idle and auto-approval is disabled", () => { + const idleTask = { + isStreaming: false, + isWaitingForFirstChunk: false, + didCompleteReadingStream: true, + presentAssistantMessageLocked: false, + } as Task + + mockProvider.getCurrentCline = jest.fn().mockReturnValue(idleTask) + mockProvider.contextProxy.getValue = jest.fn().mockReturnValue(false) + + expect(isInAutomatedWorkflowFromProvider(mockProvider)).toBe(false) + }) + + it("should handle undefined values from contextProxy", () => { + const streamingTask = { + isStreaming: true, + isWaitingForFirstChunk: false, + didCompleteReadingStream: true, + presentAssistantMessageLocked: false, + } as Task + + mockProvider.getCurrentCline = jest.fn().mockReturnValue(streamingTask) + mockProvider.contextProxy.getValue = jest.fn().mockReturnValue(undefined) + + // Should still return true because isStreaming is true, regardless of autoApprovalEnabled value + expect(isInAutomatedWorkflowFromProvider(mockProvider)).toBe(true) + }) + }) + + describe("isInAutomatedWorkflowFromVisibleProvider", () => { + it("should return false when no visible provider", () => { + mockGetVisibleInstance.mockReturnValue(null) + + expect(isInAutomatedWorkflowFromVisibleProvider()).toBe(false) + }) + + it("should return false when visible provider has no current task", () => { + mockVisibleProvider.getCurrentCline = jest.fn().mockReturnValue(null) + mockVisibleProvider.contextProxy.getValue = jest.fn().mockReturnValue(false) + mockGetVisibleInstance.mockReturnValue(mockVisibleProvider) + + expect(isInAutomatedWorkflowFromVisibleProvider()).toBe(false) + }) + + it("should return true when visible provider has streaming task", () => { + const streamingTask = { + isStreaming: true, + isWaitingForFirstChunk: false, + didCompleteReadingStream: true, + presentAssistantMessageLocked: false, + } as Task + + mockVisibleProvider.getCurrentCline = jest.fn().mockReturnValue(streamingTask) + mockVisibleProvider.contextProxy.getValue = jest.fn().mockReturnValue(false) + mockGetVisibleInstance.mockReturnValue(mockVisibleProvider) + + expect(isInAutomatedWorkflowFromVisibleProvider()).toBe(true) + }) + + it("should return true when visible provider has auto-approval enabled with incomplete task", () => { + const incompleteTask = { + isStreaming: false, + isWaitingForFirstChunk: false, + didCompleteReadingStream: false, + presentAssistantMessageLocked: false, + } as Task + + mockVisibleProvider.getCurrentCline = jest.fn().mockReturnValue(incompleteTask) + mockVisibleProvider.contextProxy.getValue = jest.fn().mockReturnValue(true) + mockGetVisibleInstance.mockReturnValue(mockVisibleProvider) + + expect(isInAutomatedWorkflowFromVisibleProvider()).toBe(true) + }) + + it("should return false when visible provider has idle task and no auto-approval", () => { + const idleTask = { + isStreaming: false, + isWaitingForFirstChunk: false, + didCompleteReadingStream: true, + presentAssistantMessageLocked: false, + } as Task + + mockVisibleProvider.getCurrentCline = jest.fn().mockReturnValue(idleTask) + mockVisibleProvider.contextProxy.getValue = jest.fn().mockReturnValue(false) + mockGetVisibleInstance.mockReturnValue(mockVisibleProvider) + + expect(isInAutomatedWorkflowFromVisibleProvider()).toBe(false) + }) + }) + + describe("edge cases and type safety", () => { + it("should handle boolean coercion correctly", () => { + // Test that the double negation (!!) in the original function works as expected + const truthyTask = { + isStreaming: true, + } as Task + + expect(isInAutomatedWorkflow(truthyTask, false)).toBe(true) + + // Test falsy values + const falsyTask = { + isStreaming: false, + isWaitingForFirstChunk: false, + didCompleteReadingStream: true, + presentAssistantMessageLocked: false, + } as Task + + expect(isInAutomatedWorkflow(falsyTask, false)).toBe(false) + }) + + it("should handle provider with undefined getCurrentCline", () => { + const providerWithUndefinedTask = { + getCurrentCline: jest.fn().mockReturnValue(undefined), + contextProxy: { + getValue: jest.fn().mockReturnValue(false), + }, + } as unknown as ClineProvider + + expect(isInAutomatedWorkflowFromProvider(providerWithUndefinedTask)).toBe(false) + }) + + it("should handle all combinations of auto-approval conditions", () => { + // Test matrix of auto-approval conditions + const testCases = [ + // [didCompleteReadingStream, presentAssistantMessageLocked, autoApprovalEnabled, expected] + [true, false, false, false], + [true, false, true, false], + [false, false, false, false], + [false, false, true, true], // incomplete stream with auto-approval + [true, true, false, false], + [true, true, true, true], // locked message with auto-approval + [false, true, false, false], + [false, true, true, true], // both incomplete stream and locked message + ] + + testCases.forEach(([didComplete, isLocked, autoApproval, expected]) => { + const task = { + isStreaming: false, + isWaitingForFirstChunk: false, + didCompleteReadingStream: didComplete, + presentAssistantMessageLocked: isLocked, + } as Task + + expect(isInAutomatedWorkflow(task, autoApproval as boolean)).toBe(expected as boolean) + }) + }) + }) +}) diff --git a/src/utils/workflow-detection.ts b/src/utils/workflow-detection.ts new file mode 100644 index 0000000000..79afed861c --- /dev/null +++ b/src/utils/workflow-detection.ts @@ -0,0 +1,118 @@ +/** + * @fileoverview Automated workflow detection utilities for preserving chat focus during AI operations. + * + * This module provides utilities to detect when the AI is actively processing tasks, which is used + * to determine whether file operations should preserve chat focus to prevent interruption of user + * input or accidental exposure of sensitive information like API keys. + * + * Created as part of the fix for GitHub issue #4574: "chatbox loses focus during automated workflow" + * + * @author Roo Code Team + * @since v3.19.7 + */ + +import type { Task } from "../core/task/Task" +import type { ClineProvider } from "../core/webview/ClineProvider" +import { ClineProvider as ClineProviderClass } from "../core/webview/ClineProvider" + +/** + * Detects if we are currently in an automated workflow where the AI is actively processing. + * + * This utility function addresses GitHub issue #4574 - preventing chatbox focus loss during + * automated file editing workflows. When the AI is actively working (streaming, processing, + * or auto-approving), opening files should preserve chat focus to prevent accidental + * interruption of user input or API key exposure. + * + * An automated workflow is detected when: + * - AI is actively streaming responses (`isStreaming === true`) + * - AI is waiting for the first chunk of a response (`isWaitingForFirstChunk === true`) + * - Auto-approval is enabled and the task hasn't completed reading/processing + * (`autoApprovalEnabled && didCompleteReadingStream === false`) + * - Auto-approval is enabled and the assistant message is locked for processing + * (`autoApprovalEnabled && presentAssistantMessageLocked === true`) + * + * @example + * ```typescript + * // Basic usage with explicit task and settings + * const shouldPreserveFocus = isInAutomatedWorkflow(currentTask, autoApprovalEnabled); + * openFile(filePath, { preserveFocus: shouldPreserveFocus }); + * + * // Used in webview message handlers + * if (isInAutomatedWorkflow(task, autoApproval)) { + * // Preserve focus during automated operations + * openFile(path, { preserveFocus: true }); + * } + * ``` + * + * @param currentTask - The current active task, if any. Can be null/undefined when no task is active. + * @param autoApprovalEnabled - Whether auto-approval mode is currently enabled in the UI settings. + * @returns true if we're in an automated workflow and should preserve focus to prevent interruption + * @since v3.19.7 - Added as part of focus preservation fix for issue #4574 + */ +export function isInAutomatedWorkflow(currentTask: Task | null | undefined, autoApprovalEnabled: boolean): boolean { + return !!( + currentTask && + (currentTask.isStreaming || + currentTask.isWaitingForFirstChunk || + (autoApprovalEnabled && currentTask.didCompleteReadingStream === false) || + (autoApprovalEnabled && currentTask.presentAssistantMessageLocked === true)) + ) +} + +/** + * Convenience function for detecting automated workflows using a ClineProvider instance. + * + * This function extracts the current task and auto-approval state directly from the + * provider's context, making it easier to use in components that have access to a + * ClineProvider instance. + * + * @example + * ```typescript + * // In webview message handlers where provider is available + * const shouldPreserveFocus = isInAutomatedWorkflowFromProvider(provider); + * openFile(message.text!, { + * ...(message.values as OpenFileOptions), + * preserveFocus: shouldPreserveFocus, + * }); + * ``` + * + * @param provider - The ClineProvider instance to get current task and settings from + * @returns true if we're in an automated workflow and should preserve focus + * @since v3.19.7 - Added as part of focus preservation fix for issue #4574 + */ +export function isInAutomatedWorkflowFromProvider(provider: ClineProvider): boolean { + const currentTask = provider.getCurrentCline() + const autoApprovalEnabled = provider.contextProxy.getValue("autoApprovalEnabled") as boolean + return isInAutomatedWorkflow(currentTask, autoApprovalEnabled) +} + +/** + * Convenience function for detecting automated workflows from the currently visible provider. + * + * This function is particularly useful for components like file mention handlers that + * don't have direct access to a ClineProvider instance but need to determine if they're + * in an automated workflow context. It uses the static `getVisibleInstance()` method + * to access the currently active provider. + * + * @example + * ```typescript + * // In mention handlers or other components without provider access + * export async function openMention(mention?: string): Promise { + * if (mention?.startsWith('/')) { + * const shouldPreserveFocus = isInAutomatedWorkflowFromVisibleProvider(); + * openFile(absPath, { preserveFocus: shouldPreserveFocus }); + * } + * } + * ``` + * + * @returns true if we're in an automated workflow and should preserve focus, + * false if no visible provider exists or not in automated workflow + * @since v3.19.7 - Added as part of focus preservation fix for issue #4574 + */ +export function isInAutomatedWorkflowFromVisibleProvider(): boolean { + const visibleProvider = ClineProviderClass.getVisibleInstance() + if (!visibleProvider) { + return false + } + return isInAutomatedWorkflowFromProvider(visibleProvider) +}