Skip to content

Commit b385aef

Browse files
committed
feat(flow-chat): focus composer when typing
1 parent fe19461 commit b385aef

3 files changed

Lines changed: 137 additions & 18 deletions

File tree

src/web-ui/src/flow_chat/components/ChatInput.tsx

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4325,24 +4325,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({
43254325
});
43264326
}, []);
43274327

4328-
// Space-to-focus: when no editable element is focused, Space key focuses the input.
4329-
useEffect(() => {
4330-
const handleGlobalKeyDown = (e: KeyboardEvent) => {
4331-
if (e.key !== ' ') return;
4332-
const target = e.target as HTMLElement;
4333-
const isEditable =
4334-
target.tagName === 'INPUT' ||
4335-
target.tagName === 'TEXTAREA' ||
4336-
target.isContentEditable ||
4337-
target.closest('[contenteditable="true"]') !== null;
4338-
if (isEditable) return;
4339-
e.preventDefault();
4340-
focusRichTextInputSoon();
4341-
};
4342-
document.addEventListener('keydown', handleGlobalKeyDown, true);
4343-
return () => document.removeEventListener('keydown', handleGlobalKeyDown, true);
4344-
}, [focusRichTextInputSoon]);
4345-
43464328
const insertSkillIntoInput = useCallback(
43474329
(skillName: string) => {
43484330
dispatchInput({ type: 'ACTIVATE' });

src/web-ui/src/flow_chat/hooks/useComposerDefaultFocus.test.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ describe('useComposerDefaultFocus', () => {
5656
return container.querySelector('[data-testid="composer"]') as HTMLDivElement;
5757
}
5858

59+
function pressKey(key: string, init: KeyboardEventInit = {}): KeyboardEvent {
60+
const event = new KeyboardEvent('keydown', {
61+
key,
62+
bubbles: true,
63+
cancelable: true,
64+
...init,
65+
});
66+
act(() => document.body.dispatchEvent(event));
67+
return event;
68+
}
69+
5970
it('focuses the composer when the active session has no input owner', () => {
6071
const composer = renderProbe({ sessionId: 'session-a', isSceneActive: true });
6172

@@ -126,8 +137,84 @@ describe('useComposerDefaultFocus', () => {
126137
button.focus();
127138

128139
const composer = renderProbe({ sessionId: 'session-a', isSceneActive: true });
140+
pressKey('a');
129141

130142
expect(document.activeElement).toBe(button);
131143
expect(document.activeElement).not.toBe(composer);
132144
});
145+
146+
it('moves focus to the composer for a printable character without consuming it', () => {
147+
const composer = renderProbe({ sessionId: 'session-a', isSceneActive: true });
148+
const button = document.createElement('button');
149+
document.body.appendChild(button);
150+
button.focus();
151+
152+
const event = pressKey('a');
153+
154+
expect(document.activeElement).toBe(composer);
155+
expect(event.defaultPrevented).toBe(false);
156+
});
157+
158+
it('moves focus to the composer for Space without consuming it', () => {
159+
const composer = renderProbe({ sessionId: 'session-a', isSceneActive: true });
160+
const button = document.createElement('button');
161+
document.body.appendChild(button);
162+
button.focus();
163+
164+
const event = pressKey(' ');
165+
166+
expect(document.activeElement).toBe(composer);
167+
expect(event.defaultPrevented).toBe(false);
168+
});
169+
170+
it.each(['Dead', 'Process'])('focuses the composer for %s text entry', key => {
171+
const composer = renderProbe({ sessionId: 'session-a', isSceneActive: true });
172+
const button = document.createElement('button');
173+
document.body.appendChild(button);
174+
button.focus();
175+
176+
pressKey(key);
177+
178+
expect(document.activeElement).toBe(composer);
179+
});
180+
181+
it('does not steal keyboard shortcuts or navigation keys', () => {
182+
const composer = renderProbe({ sessionId: 'session-a', isSceneActive: true });
183+
const button = document.createElement('button');
184+
document.body.appendChild(button);
185+
186+
for (const [key, init] of [
187+
['k', { ctrlKey: true }],
188+
['k', { metaKey: true }],
189+
['k', { altKey: true }],
190+
['Enter', {}],
191+
['ArrowDown', {}],
192+
] satisfies Array<[string, KeyboardEventInit]>) {
193+
button.focus();
194+
pressKey(key, init);
195+
expect(document.activeElement).toBe(button);
196+
expect(document.activeElement).not.toBe(composer);
197+
}
198+
});
199+
200+
it('preserves another visible text input while typing', () => {
201+
const alternateInput = document.createElement('textarea');
202+
document.body.appendChild(alternateInput);
203+
markRendered(alternateInput);
204+
alternateInput.focus();
205+
206+
const composer = renderProbe({ sessionId: 'session-a', isSceneActive: true });
207+
pressKey('a');
208+
209+
expect(document.activeElement).toBe(alternateInput);
210+
expect(document.activeElement).not.toBe(composer);
211+
});
212+
213+
it('does not focus an inactive scene while typing', () => {
214+
const composer = renderProbe({ sessionId: 'session-a', isSceneActive: false });
215+
216+
pressKey('a');
217+
218+
expect(document.activeElement).not.toBe(composer);
219+
});
133220
});

src/web-ui/src/flow_chat/hooks/useComposerDefaultFocus.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,22 @@ function hasBlockingModal(): boolean {
4747
return document.querySelector('[role="dialog"][aria-modal="true"]') !== null;
4848
}
4949

50+
function isTextEntryKey(event: KeyboardEvent): boolean {
51+
if (event.defaultPrevented || event.metaKey) {
52+
return false;
53+
}
54+
55+
const usesAltGraph = event.getModifierState?.('AltGraph') ?? false;
56+
if ((event.ctrlKey || event.altKey) && !usesAltGraph) {
57+
return false;
58+
}
59+
60+
return event.key.length === 1
61+
|| event.key === 'Dead'
62+
|| event.key === 'Process'
63+
|| event.keyCode === 229;
64+
}
65+
5066
interface UseComposerDefaultFocusOptions {
5167
editorRef: RefObject<HTMLElement | null>;
5268
sessionId: string | null;
@@ -106,4 +122,38 @@ export function useComposerDefaultFocus({
106122
}
107123
};
108124
}, [focusComposerIfUnowned]);
125+
126+
useEffect(() => {
127+
const focusComposerForTextEntry = (event: KeyboardEvent) => {
128+
if (
129+
!sceneActiveRef.current
130+
|| !sessionIdRef.current
131+
|| !isTextEntryKey(event)
132+
|| hasBlockingModal()
133+
) {
134+
return;
135+
}
136+
137+
const editor = editorRef.current;
138+
if (
139+
!editor
140+
|| !editor.isConnected
141+
|| editor.getAttribute('contenteditable') === 'false'
142+
|| editor.getAttribute('aria-disabled') === 'true'
143+
|| document.activeElement === editor
144+
|| hasVisibleTextInputFocus()
145+
) {
146+
return;
147+
}
148+
149+
// Focus synchronously and leave the event untouched. The browser can then
150+
// apply this same keystroke (including IME/dead-key input) to the composer.
151+
editor.focus({ preventScroll: true });
152+
};
153+
154+
document.addEventListener('keydown', focusComposerForTextEntry, true);
155+
return () => {
156+
document.removeEventListener('keydown', focusComposerForTextEntry, true);
157+
};
158+
}, [editorRef]);
109159
}

0 commit comments

Comments
 (0)