From 1fce7e1f3cb781fd2bf53b9997c0e369813cf99b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 28 Jul 2026 14:23:23 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=AC=20refactor:=20Raise=20`ask=5Fuser?= =?UTF-8?q?=5Fquestion`=20Option=20Label=20Cap=20to=20280=20Chars=20(#1449?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 💬 fix: Raise ask_user_question Option Label Cap to 280 Chars Raise OPTION_LABEL_MAX from 120 to 280 and make every ask_user_question surface wrap long, model-generated strings instead of overflowing. * 🪟 fix: Bound ask_user_question Popover to the Viewport The popover is absolutely positioned, so content taller than the viewport is unreachable by page scroll. Cap the panel at 60vh with the option list as the only flexible scroll region, and scroll the keyboard-selected row into view since selection paints a highlight without moving focus. --- .../Chat/Input/AskUserQuestionPopover.tsx | 106 ++++++++++++------ .../Chat/Messages/Content/AskUserQuestion.tsx | 9 +- .../Messages/Content/AskUserQuestionCall.tsx | 12 +- .../agents/hitl/askUserQuestionTool.spec.ts | 10 +- .../src/agents/hitl/askUserQuestionTool.ts | 2 +- .../api/src/agents/toolValidation.spec.ts | 2 +- 6 files changed, 92 insertions(+), 49 deletions(-) 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', ), );