diff --git a/client/src/components/Chat/Input/AskUserQuestionPopover.tsx b/client/src/components/Chat/Input/AskUserQuestionPopover.tsx index 7135f1c403..1df592e213 100644 --- a/client/src/components/Chat/Input/AskUserQuestionPopover.tsx +++ b/client/src/components/Chat/Input/AskUserQuestionPopover.tsx @@ -1,4 +1,4 @@ -import { memo } from 'react'; +import { memo, useEffect, useRef } from 'react'; import { useWatch } from 'react-hook-form'; import { Button } from '@librechat/client'; import { Check, ChevronDown, CornerDownLeft, TriangleAlert, X } from 'lucide-react'; @@ -71,6 +71,29 @@ function AskUserQuestionPopoverPanel({ handlePopoverKeyDown, } = ask; + /** Keyboard selection only paints a highlight (no focus move), so the + * scrollable option list has to follow `selected` itself or arrow/digit + * navigation can land on a row that is scrolled out of view. Manual + * scrollTop math rather than scrollIntoView: it cannot disturb the page. */ + const listRef = useRef(null); + const optionRefs = useRef<(HTMLButtonElement | null)[]>([]); + useEffect(() => { + if (typeof selected !== 'number') { + return; + } + const list = listRef.current; + const row = optionRefs.current[selected]; + if (list == null || row == null) { + return; + } + const rowBottom = row.offsetTop + row.offsetHeight; + if (row.offsetTop < list.scrollTop) { + list.scrollTop = row.offsetTop; + } else if (rowBottom > list.scrollTop + list.clientHeight) { + list.scrollTop = rowBottom - list.clientHeight; + } + }, [selected]); + if (!liveAsk) { return null; } @@ -81,16 +104,22 @@ function AskUserQuestionPopoverPanel({
{/* Digit shortcuts (1..N) work when focus is inside the popover too, not only from the composer — keydown bubbles here from the focused row/ - control. */} + control. Height is viewport-bounded with the option list as the only + scroll region: the panel is absolutely positioned, so anything that + overflows it is unreachable by page scroll. */}
-
-
-

{liveAsk.question.question}

+
+
+

+ {liveAsk.question.question} +

{liveAsk.question.description != null && liveAsk.question.description.length > 0 && ( -

{liveAsk.question.description}

+

+ {liveAsk.question.description} +

)}
@@ -112,46 +141,51 @@ function AskUserQuestionPopoverPanel({
- {options.map((option, index) => { - const isChecked = multiSelect && checked.includes(index); - return ( - - ); - })} + + {isChecked ? + {option.label} + + ); + })} +
{/** A failed submission keeps the question answerable (controls stay * enabled), but the chat card that would show the error is hidden * while the popover is up — so surface it here for retry guidance. */} {errored && ( -
+
)} -
+
{question?.question != null && ( -

{question.question}

+

+ {question.question} +

)}

{localize('com_ui_question_failed_description')} @@ -91,14 +93,16 @@ export default function AskUserQuestionCall({

-

+

{question?.question ?? (answered ? localize('com_ui_asked') : localize('com_ui_asking'))}

{question?.description != null && question.description.length > 0 && ( -

{question.description}

+

+ {question.description} +

)} {answered ? ( -

+

{localize('com_ui_you_answered')}{' '} {answerLabel}

diff --git a/packages/api/src/agents/hitl/askUserQuestionTool.spec.ts b/packages/api/src/agents/hitl/askUserQuestionTool.spec.ts index 02cf16b092..75a7e6e654 100644 --- a/packages/api/src/agents/hitl/askUserQuestionTool.spec.ts +++ b/packages/api/src/agents/hitl/askUserQuestionTool.spec.ts @@ -80,11 +80,11 @@ describe('ask_user_question tool contract', () => { type: 'tool_call', args: { question: 'How should I get the data?', - options: [{ label: 'x'.repeat(161), value: 'public-data' }], + options: [{ label: 'x'.repeat(281), value: 'public-data' }], }, }), ).rejects.toThrow( - 'Option labels must be 120 characters or fewer. Shorten the label and retry.', + 'Option labels must be 280 characters or fewer. Shorten the label and retry.', ); expect(validationErrors).toEqual( new Map([['tool-1', { fieldPath: 'options[0].label', isLengthLimit: true }]]), @@ -149,7 +149,7 @@ describe('ask_user_question tool contract', () => { ).toBe(true); expect( AskUserQuestionToolDefinition.schema.properties.options.items.properties.label.maxLength, - ).toBe(120); + ).toBe(280); expect( askUserQuestionToolSchema.safeParse({ question: 'pick', @@ -163,10 +163,10 @@ describe('ask_user_question tool contract', () => { expect(instance.description).toBe(AskUserQuestionToolDefinition.description); expect(instance.description).toContain('exactly ONE question per turn'); expect(instance.description).toContain('NEVER call this tool in parallel'); - expect(instance.description).toContain('option label within 120 characters'); + expect(instance.description).toContain('option label within 280 characters'); expect( AskUserQuestionToolDefinition.schema.properties.options.items.properties.label.description, - ).toContain('Maximum 120 characters'); + ).toContain('Maximum 280 characters'); }); }); }); diff --git a/packages/api/src/agents/hitl/askUserQuestionTool.ts b/packages/api/src/agents/hitl/askUserQuestionTool.ts index 10f5eae071..2a17f30f28 100644 --- a/packages/api/src/agents/hitl/askUserQuestionTool.ts +++ b/packages/api/src/agents/hitl/askUserQuestionTool.ts @@ -20,7 +20,7 @@ export const ASK_USER_QUESTION_TOOL_NAME = 'ask_user_question'; */ const QUESTION_MAX = 2000; const DESCRIPTION_MAX = 4000; -const OPTION_LABEL_MAX = 120; +const OPTION_LABEL_MAX = 280; const OPTION_VALUE_MAX = 500; const OPTIONS_MAX = 12; diff --git a/packages/api/src/agents/toolValidation.spec.ts b/packages/api/src/agents/toolValidation.spec.ts index c079da54cd..c6d6325a6e 100644 --- a/packages/api/src/agents/toolValidation.spec.ts +++ b/packages/api/src/agents/toolValidation.spec.ts @@ -9,7 +9,7 @@ describe('getToolInputValidationDetails', () => { const validationError = parseToolInputValidationError( new Error( 'Received tool input did not match expected schema\n' + - '✖ Option labels must be 120 characters or fewer. Shorten the label and retry.\n' + + '✖ Option labels must be 280 characters or fewer. Shorten the label and retry.\n' + ' → at options[0].label', ), );