Skip to content

Commit e63ed48

Browse files
committed
fix: restore focus to chat text box after Alt+Tab (#2189)
- Add window focus event listener to ChatView component - Automatically restore focus when window regains focus and conditions are met - Use setTimeout to ensure focus happens after event loop cycle - Add comprehensive tests for focus restoration functionality - Properly clean up event listener on component unmount This fix addresses the issue where users lose focus from the chat text box when switching away from VSCode using Alt+Tab and then switching back.
1 parent b2dadf9 commit e63ed48

File tree

2 files changed

+120
-13
lines changed

2 files changed

+120
-13
lines changed

webview-ui/src/components/chat/ChatView.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,27 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
792792
[isHidden, sendingDisabled, enableButtons],
793793
)
794794

795+
// Effect to restore focus to the text area when the window regains focus
796+
useEffect(() => {
797+
const handleWindowFocus = () => {
798+
// Check if the view is visible and the text area should be focusable
799+
if (!isHidden && !sendingDisabled && !enableButtons) {
800+
// Use setTimeout to ensure focus happens after the event loop cycle
801+
setTimeout(() => {
802+
textAreaRef.current?.focus()
803+
}, 0)
804+
}
805+
}
806+
807+
window.addEventListener("focus", handleWindowFocus)
808+
809+
// Cleanup listener on component unmount
810+
return () => {
811+
window.removeEventListener("focus", handleWindowFocus)
812+
}
813+
// Dependencies ensure the effect re-runs if these conditions change
814+
}, [isHidden, sendingDisabled, enableButtons])
815+
795816
const visibleMessages = useMemo(() => {
796817
const newVisibleMessages = modifiedMessages.filter((message) => {
797818
if (everVisibleMessagesTsRef.current.has(message.ts)) {

webview-ui/src/components/chat/__tests__/ChatView.spec.tsx

Lines changed: 99 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,13 @@ vi.mock("@src/utils/vscode", () => ({
3636
},
3737
}))
3838

39-
// Mock use-sound hook
39+
// Mock use-sound hook - must be defined before any imports that use it
4040
const mockPlayFunction = vi.fn()
41-
vi.mock("use-sound", () => ({
42-
default: vi.fn().mockImplementation(() => {
43-
return [mockPlayFunction]
44-
}),
45-
}))
41+
vi.mock("use-sound", () => {
42+
return {
43+
default: vi.fn(() => [mockPlayFunction, { stop: vi.fn(), pause: vi.fn() }]),
44+
}
45+
})
4646

4747
// Mock components that use ESM dependencies
4848
vi.mock("../BrowserSessionRow", () => ({
@@ -107,6 +107,7 @@ vi.mock("react-i18next", () => ({
107107
interface ChatTextAreaProps {
108108
onSend: (value: string) => void
109109
inputValue?: string
110+
setInputValue?: (value: string) => void
110111
sendingDisabled?: boolean
111112
placeholderText?: string
112113
selectedImages?: string[]
@@ -123,16 +124,28 @@ vi.mock("../ChatTextArea", () => {
123124
return {
124125
default: mockReact.forwardRef(function MockChatTextArea(
125126
props: ChatTextAreaProps,
126-
ref: React.ForwardedRef<{ focus: () => void }>,
127+
ref: React.ForwardedRef<HTMLTextAreaElement>,
127128
) {
128-
// Use useImperativeHandle to expose the mock focus method
129-
React.useImperativeHandle(ref, () => ({
130-
focus: mockFocus,
131-
}))
129+
// Create a mock textarea element with focus method
130+
mockReact.useImperativeHandle(
131+
ref,
132+
() => ({
133+
focus: mockFocus,
134+
blur: vi.fn(),
135+
value: props.inputValue || "",
136+
}),
137+
[props.inputValue],
138+
)
132139

133140
return (
134-
<div data-testid="chat-textarea">
135-
<input ref={mockInputRef} type="text" onChange={(e) => props.onSend(e.target.value)} />
141+
<div data-testid="chat-textarea" data-sending-disabled={props.sendingDisabled}>
142+
<input
143+
ref={mockInputRef}
144+
type="text"
145+
value={props.inputValue || ""}
146+
onChange={(e) => props.setInputValue?.(e.target.value)}
147+
disabled={props.sendingDisabled}
148+
/>
136149
</div>
137150
)
138151
}),
@@ -191,6 +204,8 @@ const mockPostMessage = (state: Partial<ExtensionState>) => {
191204
shouldShowAnnouncement: false,
192205
allowedCommands: [],
193206
alwaysAllowExecute: false,
207+
soundEnabled: true,
208+
soundVolume: 0.5,
194209
...state,
195210
},
196211
},
@@ -216,6 +231,77 @@ const renderChatView = (props: Partial<ChatViewProps> = {}) => {
216231
)
217232
}
218233

234+
describe("ChatView - Window Focus Tests", () => {
235+
beforeEach(() => {
236+
vi.clearAllMocks()
237+
// Reset focus mock
238+
mockFocus.mockClear()
239+
})
240+
241+
afterEach(() => {
242+
// Clean up any event listeners
243+
vi.restoreAllMocks()
244+
})
245+
246+
it("should set up and clean up window focus event listener", () => {
247+
const addEventListenerSpy = vi.spyOn(window, "addEventListener")
248+
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener")
249+
250+
const { unmount } = renderChatView({ isHidden: false })
251+
252+
// Check that the event listener was added
253+
expect(addEventListenerSpy).toHaveBeenCalledWith("focus", expect.any(Function))
254+
255+
// Unmount the component
256+
unmount()
257+
258+
// Check that the event listener was removed
259+
expect(removeEventListenerSpy).toHaveBeenCalledWith("focus", expect.any(Function))
260+
261+
addEventListenerSpy.mockRestore()
262+
removeEventListenerSpy.mockRestore()
263+
})
264+
265+
it("should restore focus when window regains focus and conditions are met", async () => {
266+
renderChatView({ isHidden: false })
267+
268+
// Hydrate state to enable the text area
269+
mockPostMessage({
270+
clineMessages: [
271+
{
272+
type: "say",
273+
say: "text",
274+
ts: Date.now(),
275+
text: "Hello",
276+
},
277+
],
278+
})
279+
280+
// Wait for initial render
281+
await waitFor(() => {
282+
expect(document.querySelector('[data-testid="chat-textarea"]')).toBeInTheDocument()
283+
})
284+
285+
// Wait for any initial focus calls to complete
286+
await act(async () => {
287+
await new Promise((resolve) => setTimeout(resolve, 100))
288+
})
289+
290+
// Clear the mock to test only the window focus event
291+
mockFocus.mockClear()
292+
293+
// Simulate window focus event
294+
await act(async () => {
295+
window.dispatchEvent(new Event("focus"))
296+
// Wait for the setTimeout in the focus handler
297+
await new Promise((resolve) => setTimeout(resolve, 10))
298+
})
299+
300+
// The focus should have been called
301+
expect(mockFocus).toHaveBeenCalledTimes(1)
302+
})
303+
})
304+
219305
describe("ChatView - Auto Approval Tests", () => {
220306
beforeEach(() => vi.clearAllMocks())
221307

0 commit comments

Comments
 (0)