Skip to content

Commit 79c7051

Browse files
committed
fix: Honor a Submit Shortcut Rebound Off Enter in Ask Answer Fields
The window shortcut handler yields submitMessage in every editing field except the composer, so a chord such as Ctrl+J did nothing in the ask answer fields. Both the single-question answer box and the batched answer field now act on it, through a shared isReboundSubmitChord.
1 parent 4a53fe9 commit 79c7051

6 files changed

Lines changed: 129 additions & 8 deletions

File tree

‎client/src/components/Chat/Messages/Content/AskUserQuestion.tsx‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import { ChevronDown, MessageCircleQuestion, TriangleAlert } from 'lucide-react'
44
import type { Agents } from 'librechat-data-provider';
55
import type { KeyboardEvent } from 'react';
66
import type { ComposerBindings } from '~/hooks/Input/useComposerBindings';
7+
import { isCancelKey, isReboundSubmitChord, resolveComposerKeyDown } from '~/utils/shortcuts';
78
import { useApprovalContext, useAskSubmitStatus, useResumeSubmit } from './ApprovalContext';
89
import { splitOtherOption, ASK_USER_DECLINED_ANSWER } from '~/utils/approval';
910
import { useOptionalChatSurface } from '~/components/Chat/Subagents/surface';
10-
import { isCancelKey, resolveComposerKeyDown } from '~/utils/shortcuts';
1111
import { cn, forceResize, insertTextAtCursor } from '~/utils';
1212
import useAskAnswerMode from '~/hooks/Input/useAskAnswerMode';
1313
import AskOptions from '~/components/Chat/ask/options';
@@ -240,8 +240,16 @@ function AskUserQuestionSingle({
240240
* steer/queue actions.
241241
*/
242242
const handleAnswerKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
243+
const isComposing = e.nativeEvent.isComposing || e.key === 'Process' || e.keyCode === 229;
244+
if (!isComposing && isReboundSubmitChord(e.nativeEvent, { shortcutsEnabled, submitOverride })) {
245+
e.preventDefault();
246+
if (canSubmit && !locked) {
247+
submitCombined();
248+
}
249+
return;
250+
}
243251
const action = resolveComposerKeyDown(e.nativeEvent, {
244-
isComposing: e.nativeEvent.isComposing || e.key === 'Process' || e.keyCode === 229,
252+
isComposing,
245253
isSubmitting: false,
246254
allowSubmitWhileGenerating: false,
247255
hasDuringRunModifier: false,

‎client/src/components/Chat/Messages/Content/AskUserQuestions.tsx‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import { TriangleAlert } from 'lucide-react';
33
import { Input, Button } from '@librechat/client';
44
import type { Agents } from 'librechat-data-provider';
55
import type { KeyboardEvent, ReactNode } from 'react';
6+
import { useOptionalChatSurface } from '~/components/Chat/Subagents/surface';
7+
import { isCancelKey, isReboundSubmitChord } from '~/utils/shortcuts';
68
import useAskQuestionsForm from '~/hooks/Input/useAskQuestionsForm';
79
import AskOptions from '~/components/Chat/ask/options';
810
import { splitOtherOption } from '~/utils/approval';
9-
import { isCancelKey } from '~/utils/shortcuts';
1011
import { AutoHeight } from '~/components/ui';
1112
import { mainTextareaId } from '~/common';
1213
import { useLocalize } from '~/hooks';
@@ -41,6 +42,7 @@ export default function AskUserQuestions({
4142
const localize = useLocalize();
4243
const promptId = useId();
4344
const form = useAskQuestionsForm(actionId, questions);
45+
const composerBindings = useOptionalChatSurface()?.composerBindings;
4446
const { goToStep, selectOption } = form;
4547

4648
const scrollRef = useRef<HTMLDivElement>(null);
@@ -331,16 +333,26 @@ export default function AskUserQuestions({
331333
/* The composer popover sits inside the chat form, where Enter in a
332334
single-line field would submit the composer draft instead.
333335
Enter (or Ctrl/Cmd+Enter) confirms this answer: the next step,
334-
or the batch. Shift+Enter and Alt+Enter never submit, as in the
336+
or the batch, and so does a submit shortcut rebound off Enter.
337+
Shift+Enter and Alt+Enter never submit, as in the
335338
composer, and neither does a held Enter repeating into the next
336339
step; those are still kept from submitting the form. An Enter
337340
that confirms an IME composition is left alone, with the same
338341
Safari fallback as the composer. */
342+
const composing =
343+
event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229;
339344
if (
340-
event.key !== 'Enter' ||
341-
event.nativeEvent.isComposing ||
342-
event.nativeEvent.keyCode === 229
345+
!composing &&
346+
composerBindings != null &&
347+
isReboundSubmitChord(event.nativeEvent, composerBindings)
343348
) {
349+
event.preventDefault();
350+
if (!event.repeat) {
351+
confirmStep();
352+
}
353+
return;
354+
}
355+
if (event.key !== 'Enter' || composing) {
344356
return;
345357
}
346358
event.preventDefault();

‎client/src/components/Chat/Messages/Content/__tests__/AskUserQuestion.test.tsx‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import { RecoilRoot } from 'recoil';
33
import { fireEvent, render, screen } from '@testing-library/react';
44
import type { Agents } from 'librechat-data-provider';
5+
import type { ShortcutBinding } from '~/utils/shortcuts';
56
import { ChatSurfaceProvider } from '~/components/Chat/Subagents/surface';
67
import { ASK_USER_DECLINED_ANSWER } from '~/utils/approval';
78
import ApprovalProvider from '../ApprovalContext';
@@ -67,6 +68,7 @@ jest.mock('~/Providers/ChatContext', () => ({
6768
}));
6869

6970
const yieldedChords = new Set<string>();
71+
let submitOverride: ShortcutBinding | undefined;
7072

7173
const tree = (
7274
key: string,
@@ -79,7 +81,7 @@ const tree = (
7981
enterToSend,
8082
maximizeChatSpace: false,
8183
showScrollButton: false,
82-
composerBindings: { shortcutsEnabled: true, submitOverride: undefined, yieldedChords },
84+
composerBindings: { shortcutsEnabled: true, submitOverride, yieldedChords },
8385
claimForeground: jest.fn(),
8486
handOffComposerText: jest.fn(),
8587
}}
@@ -99,6 +101,7 @@ describe('AskUserQuestion', () => {
99101
mockLiveActionId = null;
100102
mockChecked = [];
101103
mockAnswerText = '';
104+
submitOverride = undefined;
102105
});
103106

104107
test('restores a typed answer after the card remounts inside the same message', () => {
@@ -175,6 +178,13 @@ describe('AskUserQuestion', () => {
175178
expect(mockSubmitAnswer).not.toHaveBeenCalled();
176179
});
177180

181+
test('a submit shortcut rebound off Enter submits the answer', () => {
182+
submitOverride = { meta: false, ctrl: true, alt: false, shift: false, key: 'J' };
183+
render(liveTree(true));
184+
fireEvent.keyDown(answerBox(), { key: 'j', ctrlKey: true });
185+
expect(mockSubmitAnswer).toHaveBeenCalledWith(['Use staging first']);
186+
});
187+
178188
test('does not submit an empty answer', () => {
179189
render(liveTree(true, ' '));
180190
fireEvent.keyDown(answerBox(), { key: 'Enter' });

‎client/src/components/Chat/Messages/Content/__tests__/AskUserQuestions.test.tsx‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import { RecoilRoot } from 'recoil';
33
import { fireEvent, render, screen } from '@testing-library/react';
44
import type { Agents } from 'librechat-data-provider';
5+
import { ChatSurfaceProvider } from '~/components/Chat/Subagents/surface';
56
import { ASK_USER_DECLINED_ANSWER } from '~/utils/approval';
67
import AskUserQuestions from '../AskUserQuestions';
78

@@ -326,6 +327,38 @@ describe('AskUserQuestions', () => {
326327
expect(onSubmit).not.toHaveBeenCalled();
327328
});
328329

330+
test('a submit shortcut rebound off Enter confirms the answer like Enter', () => {
331+
render(
332+
<RecoilRoot>
333+
<ChatSurfaceProvider
334+
value={{
335+
enterToSend: false,
336+
maximizeChatSpace: false,
337+
showScrollButton: false,
338+
composerBindings: {
339+
shortcutsEnabled: true,
340+
submitOverride: { meta: false, ctrl: true, alt: false, shift: false, key: 'J' },
341+
yieldedChords: new Set(),
342+
},
343+
claimForeground: jest.fn(),
344+
handOffComposerText: jest.fn(),
345+
}}
346+
>
347+
<AskUserQuestions actionId="ask-rebound-submit" questions={questions} />
348+
</ChatSurfaceProvider>
349+
</RecoilRoot>,
350+
);
351+
const first = screen.getByRole('textbox', { name: /Where should this run/ });
352+
fireEvent.change(first, { target: { value: 'Locally' } });
353+
fireEvent.keyDown(first, { key: 'j', ctrlKey: true });
354+
expect(isShown('Which time window?')).toBe(true);
355+
356+
const last = screen.getByRole('textbox', { name: /Which time window/ });
357+
fireEvent.change(last, { target: { value: 'Today' } });
358+
fireEvent.keyDown(last, { key: 'j', ctrlKey: true });
359+
expect(mockSubmitAskAnswer).toHaveBeenCalledTimes(1);
360+
});
361+
329362
describe('focus on arrival', () => {
330363
afterEach(() => {
331364
document.getElementById('prompt-textarea')?.remove();

‎client/src/utils/shortcuts.spec.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ShortcutBinding, ComposerKeyContext } from './shortcuts';
22
import {
33
hasModifier,
44
isCancelKey,
5+
isReboundSubmitChord,
56
bindingHash,
67
normalizeKey,
78
parseBinding,
@@ -222,6 +223,46 @@ describe('resolveSubmitOverrideAction', () => {
222223
});
223224
});
224225

226+
describe('isReboundSubmitChord', () => {
227+
const ctrlJ = makeBinding({ ctrl: true, key: 'J' });
228+
const press = (init: KeyboardEventInit) => new KeyboardEvent('keydown', init);
229+
230+
it('matches a submit chord rebound off Enter', () => {
231+
expect(
232+
isReboundSubmitChord(press({ key: 'j', ctrlKey: true }), {
233+
shortcutsEnabled: true,
234+
submitOverride: ctrlJ,
235+
}),
236+
).toBe(true);
237+
});
238+
239+
it('leaves Enter chords, other keys, unset or unbound overrides and disabled shortcuts alone', () => {
240+
const ctrlEnter = makeBinding({ ctrl: true, key: 'Enter' });
241+
const event = press({ key: 'j', ctrlKey: true });
242+
expect(
243+
isReboundSubmitChord(press({ key: 'Enter', ctrlKey: true }), {
244+
shortcutsEnabled: true,
245+
submitOverride: ctrlEnter,
246+
}),
247+
).toBe(false);
248+
expect(
249+
isReboundSubmitChord(press({ key: 'k', ctrlKey: true }), {
250+
shortcutsEnabled: true,
251+
submitOverride: ctrlJ,
252+
}),
253+
).toBe(false);
254+
expect(isReboundSubmitChord(event, { shortcutsEnabled: true, submitOverride: undefined })).toBe(
255+
false,
256+
);
257+
expect(isReboundSubmitChord(event, { shortcutsEnabled: true, submitOverride: null })).toBe(
258+
false,
259+
);
260+
expect(isReboundSubmitChord(event, { shortcutsEnabled: false, submitOverride: ctrlJ })).toBe(
261+
false,
262+
);
263+
});
264+
});
265+
225266
describe('isCancelKey', () => {
226267
it('is true for a bare Escape press', () => {
227268
expect(isCancelKey(new KeyboardEvent('keydown', { key: 'Escape' }))).toBe(true);

‎client/src/utils/shortcuts.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,23 @@ export function resolveComposerKeyDown(
321321
return 'none';
322322
}
323323

324+
/**
325+
* A `submitMessage` chord rebound off Enter. The window-level handler yields
326+
* it in every editing field but the composer, so an answer field that submits
327+
* like the composer has to act on it itself; Enter chords go through
328+
* `resolveComposerKeyDown`.
329+
*/
330+
export function isReboundSubmitChord(
331+
e: KeyChordSource,
332+
ctx: Pick<ComposerKeyContext, 'shortcutsEnabled' | 'submitOverride'>,
333+
): boolean {
334+
const { submitOverride } = ctx;
335+
if (!ctx.shortcutsEnabled || submitOverride == null || submitOverride.key === 'Enter') {
336+
return false;
337+
}
338+
return bindingsMatch(bindingFromEvent(e), submitOverride);
339+
}
340+
324341
export function isCancelKey(e: KeyboardEvent): boolean {
325342
return e.key === 'Escape' && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey;
326343
}

0 commit comments

Comments
 (0)