diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 13121531a7..8de0b097bf 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -87,6 +87,7 @@ export class ClineProvider return this._workspaceTracker } protected mcpHub?: McpHub // Change from private to protected + private focusTimeoutId: NodeJS.Timeout | undefined public isViewLaunched = false public settingsImportedAt?: number @@ -220,6 +221,11 @@ export class ClineProvider await this.removeClineFromStack() this.log("Cleared task") + if (this.focusTimeoutId) { + clearTimeout(this.focusTimeoutId) + this.focusTimeoutId = undefined + } + if (this.view && "dispose" in this.view) { this.view.dispose() this.log("Disposed webview") @@ -447,6 +453,25 @@ export class ClineProvider this.disposables, ) + vscode.window.onDidChangeWindowState?.( + (windowState) => { + if (windowState.focused && this.view?.visible) { + if (this.focusTimeoutId) { + clearTimeout(this.focusTimeoutId) + } + + this.focusTimeoutId = setTimeout(() => { + if (this.view?.visible) { + this.postMessageToWebview({ type: "action", action: "focusInput" }) + } + this.focusTimeoutId = undefined + }, 100) + } + }, + null, + this.disposables, + ) + // Listen for when color changes vscode.workspace.onDidChangeConfiguration( async (e) => { diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index f141dace36..81a1758738 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -150,6 +150,9 @@ jest.mock("vscode", () => ({ window: { showInformationMessage: jest.fn(), showErrorMessage: jest.fn(), + onDidChangeWindowState: jest.fn().mockImplementation(() => ({ + dispose: jest.fn(), + })), }, workspace: { getConfiguration: jest.fn().mockReturnValue({ @@ -282,14 +285,12 @@ describe("ClineProvider", () => { dispose: jest.fn(), } - // Mock output channel mockOutputChannel = { appendLine: jest.fn(), clear: jest.fn(), dispose: jest.fn(), } as unknown as vscode.OutputChannel - // Mock webview mockPostMessage = jest.fn() mockWebviewView = { @@ -2397,4 +2398,168 @@ describe("ClineProvider - Router Models", () => { }, }) }) + + describe("Window Focus Handling", () => { + let provider: ClineProvider + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + let mockWebviewView: vscode.WebviewView + let mockPostMessage: jest.Mock + let mockWindowStateListeners: Array<(e: vscode.WindowState) => void> = [] + + beforeEach(() => { + jest.clearAllMocks() + + mockWindowStateListeners = [] + + ;(vscode.window.onDidChangeWindowState as jest.Mock).mockImplementation((listener) => { + mockWindowStateListeners.push(listener) + return { dispose: jest.fn() } + }) + + const globalState: Record = { + mode: "code", + } + + const secrets: Record = {} + + mockContext = { + extensionPath: "/test/path", + extensionUri: {} as vscode.Uri, + globalState: { + get: jest.fn().mockImplementation((key: string) => globalState[key]), + update: jest.fn().mockImplementation((key: string, value: string | undefined) => (globalState[key] = value)), + keys: jest.fn().mockImplementation(() => Object.keys(globalState)), + }, + secrets: { + get: jest.fn().mockImplementation((key: string) => secrets[key]), + store: jest.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), + delete: jest.fn().mockImplementation((key: string) => delete secrets[key]), + }, + subscriptions: [], + extension: { + packageJSON: { version: "1.0.0" }, + }, + globalStorageUri: { + fsPath: "/test/storage/path", + }, + } as unknown as vscode.ExtensionContext + + // Mock output channel + mockOutputChannel = { + appendLine: jest.fn(), + clear: jest.fn(), + dispose: jest.fn(), + } as unknown as vscode.OutputChannel + + // Mock webview + mockPostMessage = jest.fn() + + mockWebviewView = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: jest.fn(), + asWebviewUri: jest.fn(), + }, + visible: true, + onDidDispose: jest.fn().mockImplementation((callback) => { + callback() + return { dispose: jest.fn() } + }), + onDidChangeVisibility: jest.fn().mockImplementation(() => ({ dispose: jest.fn() })), + } as unknown as vscode.WebviewView + + provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + }) + + test("registers window state change listener on webview resolution", async () => { + await provider.resolveWebviewView(mockWebviewView) + + expect(vscode.window.onDidChangeWindowState).toHaveBeenCalled() + expect(mockWindowStateListeners.length).toBeGreaterThan(0) + }) + + test("sends focusInput message when window becomes focused and webview is visible", async () => { + await provider.resolveWebviewView(mockWebviewView) + + mockPostMessage.mockClear() + + const windowState: vscode.WindowState = { focused: true, active: true } + mockWindowStateListeners.forEach((listener) => listener(windowState)) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "action", + action: "focusInput", + }) + }) + + test("does not send focusInput message when window becomes focused but webview is not visible", async () => { + const invisibleWebviewView = { + ...mockWebviewView, + visible: false, + } + await provider.resolveWebviewView(invisibleWebviewView) + + mockPostMessage.mockClear() + + const windowState: vscode.WindowState = { focused: true, active: true } + mockWindowStateListeners.forEach((listener) => listener(windowState)) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(mockPostMessage).not.toHaveBeenCalledWith({ + type: "action", + action: "focusInput", + }) + }) + + test("does not send focusInput message when window loses focus", async () => { + await provider.resolveWebviewView(mockWebviewView) + + mockPostMessage.mockClear() + + const windowState: vscode.WindowState = { focused: false, active: false } + mockWindowStateListeners.forEach((listener) => listener(windowState)) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(mockPostMessage).not.toHaveBeenCalledWith({ + type: "action", + action: "focusInput", + }) + }) + + test("properly disposes window state listener and clears timeout", async () => { + await provider.resolveWebviewView(mockWebviewView) + + expect(vscode.window.onDidChangeWindowState).toHaveBeenCalled() + expect(mockWindowStateListeners.length).toBeGreaterThan(0) + + await new Promise(resolve => setTimeout(resolve, 10)) + + const windowState: vscode.WindowState = { focused: true, active: true } + mockWindowStateListeners.forEach((listener) => listener(windowState)) + + await new Promise(resolve => setTimeout(resolve, 10)) + + const timeoutId = (provider as any).focusTimeoutId + expect(timeoutId).toBeDefined() + + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout') + + await provider.dispose() + + expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId) + + expect((provider as any).focusTimeoutId).toBeUndefined() + + expect((provider as any).disposables).toEqual([]) + + clearTimeoutSpy.mockRestore() + }) + }) }) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 6eaceb1374..156b3360a4 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -135,6 +135,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction("") const [wasStreaming, setWasStreaming] = useState(false) const [showCheckpointWarning, setShowCheckpointWarning] = useState(false) + const focusTimeoutRef = useRef(null) const [isCondensing, setIsCondensing] = useState(false) // UI layout depends on the last 2 messages @@ -587,7 +588,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + if (!isHidden && !sendingDisabled && !enableButtons && textAreaRef.current) { + try { + textAreaRef.current.focus() + } catch (e) { + console.debug("Failed to focus input:", e) + } + } + focusTimeoutRef.current = null + }, 50) break } break @@ -650,6 +664,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction textAreaRef.current?.focus()) + // Cleanup effect for focus timeout + useEffect(() => { + return () => { + if (focusTimeoutRef.current) { + clearTimeout(focusTimeoutRef.current) + } + } + }, []) + useEffect(() => { const timer = setTimeout(() => { if (!isHidden && !sendingDisabled && !enableButtons) {