diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index f804f7b61ed..3ed309dbbad 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -792,6 +792,27 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + const handleWindowFocus = () => { + // Check if the view is visible and the text area should be focusable + if (!isHidden && !sendingDisabled && !enableButtons) { + // Use setTimeout to ensure focus happens after the event loop cycle + setTimeout(() => { + textAreaRef.current?.focus() + }, 0) + } + } + + window.addEventListener("focus", handleWindowFocus) + + // Cleanup listener on component unmount + return () => { + window.removeEventListener("focus", handleWindowFocus) + } + // Dependencies ensure the effect re-runs if these conditions change + }, [isHidden, sendingDisabled, enableButtons]) + const visibleMessages = useMemo(() => { const newVisibleMessages = modifiedMessages.filter((message) => { if (everVisibleMessagesTsRef.current.has(message.ts)) { diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index ba758fbe3b3..1b5d00e7b67 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -36,13 +36,13 @@ vi.mock("@src/utils/vscode", () => ({ }, })) -// Mock use-sound hook +// Mock use-sound hook - must be defined before any imports that use it const mockPlayFunction = vi.fn() -vi.mock("use-sound", () => ({ - default: vi.fn().mockImplementation(() => { - return [mockPlayFunction] - }), -})) +vi.mock("use-sound", () => { + return { + default: vi.fn(() => [mockPlayFunction, { stop: vi.fn(), pause: vi.fn() }]), + } +}) // Mock components that use ESM dependencies vi.mock("../BrowserSessionRow", () => ({ @@ -107,6 +107,7 @@ vi.mock("react-i18next", () => ({ interface ChatTextAreaProps { onSend: (value: string) => void inputValue?: string + setInputValue?: (value: string) => void sendingDisabled?: boolean placeholderText?: string selectedImages?: string[] @@ -123,16 +124,28 @@ vi.mock("../ChatTextArea", () => { return { default: mockReact.forwardRef(function MockChatTextArea( props: ChatTextAreaProps, - ref: React.ForwardedRef<{ focus: () => void }>, + ref: React.ForwardedRef, ) { - // Use useImperativeHandle to expose the mock focus method - React.useImperativeHandle(ref, () => ({ - focus: mockFocus, - })) + // Create a mock textarea element with focus method + mockReact.useImperativeHandle( + ref, + () => ({ + focus: mockFocus, + blur: vi.fn(), + value: props.inputValue || "", + }), + [props.inputValue], + ) return ( -
- props.onSend(e.target.value)} /> +
+ props.setInputValue?.(e.target.value)} + disabled={props.sendingDisabled} + />
) }), @@ -191,6 +204,8 @@ const mockPostMessage = (state: Partial) => { shouldShowAnnouncement: false, allowedCommands: [], alwaysAllowExecute: false, + soundEnabled: true, + soundVolume: 0.5, ...state, }, }, @@ -216,6 +231,77 @@ const renderChatView = (props: Partial = {}) => { ) } +describe("ChatView - Window Focus Tests", () => { + beforeEach(() => { + vi.clearAllMocks() + // Reset focus mock + mockFocus.mockClear() + }) + + afterEach(() => { + // Clean up any event listeners + vi.restoreAllMocks() + }) + + it("should set up and clean up window focus event listener", () => { + const addEventListenerSpy = vi.spyOn(window, "addEventListener") + const removeEventListenerSpy = vi.spyOn(window, "removeEventListener") + + const { unmount } = renderChatView({ isHidden: false }) + + // Check that the event listener was added + expect(addEventListenerSpy).toHaveBeenCalledWith("focus", expect.any(Function)) + + // Unmount the component + unmount() + + // Check that the event listener was removed + expect(removeEventListenerSpy).toHaveBeenCalledWith("focus", expect.any(Function)) + + addEventListenerSpy.mockRestore() + removeEventListenerSpy.mockRestore() + }) + + it("should restore focus when window regains focus and conditions are met", async () => { + renderChatView({ isHidden: false }) + + // Hydrate state to enable the text area + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "text", + ts: Date.now(), + text: "Hello", + }, + ], + }) + + // Wait for initial render + await waitFor(() => { + expect(document.querySelector('[data-testid="chat-textarea"]')).toBeInTheDocument() + }) + + // Wait for any initial focus calls to complete + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + }) + + // Clear the mock to test only the window focus event + mockFocus.mockClear() + + // Simulate window focus event + await act(async () => { + window.dispatchEvent(new Event("focus")) + // Wait for the setTimeout in the focus handler + await new Promise((resolve) => setTimeout(resolve, 10)) + }) + + // The focus should have been called + expect(mockFocus).toHaveBeenCalledTimes(1) + }) +}) + describe("ChatView - Auto Approval Tests", () => { beforeEach(() => vi.clearAllMocks())