style(client): rebuild the ask question surfaces on shared option rows

The popover and the chat card rendered the same question two different
ways: numbered rows in one, wrapped chip buttons in the other, and the
panel was painted with hardcoded white/gray instead of theme tokens.

Both surfaces now render one AskOptions list, so numbering, check badges,
rounding and hover states match wherever a question appears. The card
groups its prompt with the description, styles the answer box like the
composer, and gains a Skip button beside Submit so declining does not
require the popover. While the popover holds the question the card keeps
a hidden copy of itself in the thread, reserving the exact space the card
will need so nothing reflows when the question moves back.
This commit is contained in:
Marco Beretta 2026-07-31 18:58:45 +02:00
parent 8320943ba5
commit ef251a481d
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
4 changed files with 213 additions and 113 deletions

View file

@ -1,11 +1,11 @@
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';
import { Button, TooltipAnchor } from '@librechat/client';
import { ChevronDown, CornerDownLeft, TriangleAlert } from 'lucide-react';
import useAskAnswerMode from '~/hooks/Input/useAskAnswerMode';
import AskOptions from '~/components/Chat/ask/options';
import { useChatFormContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
/**
* Composer popover for a live `ask_user_question` pause. Single-select rows
@ -15,10 +15,10 @@ import { cn } from '~/utils';
* Submit confirms
* folding in any free-form text typed in the composer, exactly like Enter
* would. The footer hint is a button that focuses the composer the
* free-form answer box. Collapse (chevron) hides the popover but keeps
* answer mode live via the chat card; × dismisses it entirely. Pure
* rendering off {@link useAskAnswerMode}; disappears the moment an answer
* submits from any surface, and locks while one is in flight.
* free-form answer box. The chevron moves the question to the chat card and
* releases the composer (the card's chevron moves it back). Pure rendering
* off {@link useAskAnswerMode}; disappears the moment an answer submits from
* any surface, and locks while one is in flight.
*/
function AskUserQuestionPopoverContent({
conversationId,
@ -66,7 +66,6 @@ function AskUserQuestionPopoverPanel({
submit,
submitOption,
skip,
dismiss,
collapse,
handlePopoverKeyDown,
} = ask;
@ -108,7 +107,7 @@ function AskUserQuestionPopoverPanel({
scroll region: the panel is absolutely positioned, so anything that
overflows it is unreachable by page scroll. */}
<div
className="popover border-token-border-light flex max-h-[60vh] flex-col rounded-2xl border bg-white p-2 shadow-lg dark:bg-gray-700"
className="flex max-h-[60vh] flex-col rounded-2xl border border-border-light bg-surface-secondary p-2 shadow-lg [view-transition-name:ask-question]"
onKeyDown={handlePopoverKeyDown}
>
<div className="flex shrink-0 items-start justify-between gap-2 p-2">
@ -122,60 +121,34 @@ function AskUserQuestionPopoverPanel({
</p>
)}
</div>
<div className="flex items-center">
<button
type="button"
aria-label={localize('com_ui_collapse')}
className="rounded p-1 text-text-secondary hover:bg-surface-hover"
onClick={collapse}
>
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
aria-label={localize('com_ui_close')}
className="rounded p-1 text-text-secondary hover:bg-surface-hover"
onClick={dismiss}
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
<div ref={listRef} className="relative min-h-0 flex-1 overflow-y-auto">
{options.map((option, index) => {
const isChecked = multiSelect && checked.includes(index);
return (
{/* Single exit: moves the question to the chat card and hands the
composer back for normal messages. */}
<TooltipAnchor
description={localize('com_ui_ask_move_to_chat')}
side="top"
render={
<button
key={option.value}
ref={(el) => {
optionRefs.current[index] = el;
}}
type="button"
role={multiSelect ? 'checkbox' : undefined}
aria-checked={multiSelect ? isChecked : undefined}
disabled={locked}
className={cn(
'flex w-full items-center gap-3 rounded-lg p-2 text-left text-sm text-text-primary',
selected === index ? 'bg-surface-active' : 'hover:bg-surface-hover',
locked ? 'cursor-not-allowed opacity-60' : '',
)}
onClick={() => (multiSelect ? toggleChecked(index) : submitOption(index))}
aria-label={localize('com_ui_ask_move_to_chat')}
className="rounded-md p-1 text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
onClick={collapse}
>
<span
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded text-xs',
isChecked
? 'bg-surface-submit text-white'
: 'bg-surface-tertiary text-text-secondary',
)}
>
{isChecked ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : index + 1}
</span>
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">{option.label}</span>
<ChevronDown className="size-4" aria-hidden="true" />
</button>
);
})}
}
/>
</div>
<AskOptions
options={options}
multiSelect={multiSelect}
checked={checked}
selected={selected}
locked={locked}
onActivate={(index) => (multiSelect ? toggleChecked(index) : submitOption(index))}
optionRefs={optionRefs}
listRef={listRef}
className="relative min-h-0 flex-1 overflow-y-auto"
/>
{/** 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. */}
@ -188,7 +161,7 @@ function AskUserQuestionPopoverPanel({
<div className="flex shrink-0 items-center justify-between gap-2 p-2">
<button
type="button"
className="text-xs italic text-text-secondary hover:text-text-primary hover:underline"
className="cursor-text rounded-md text-xs text-text-secondary transition-colors hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
onClick={() => textAreaRef?.current?.focus()}
>
{options.length === 0

View file

@ -1,12 +1,14 @@
import { useContext, useMemo, useState } from 'react';
import { ChevronUp, TriangleAlert } from 'lucide-react';
import { Button, TextareaAutosize } from '@librechat/client';
import { Button, TextareaAutosize, TooltipAnchor } from '@librechat/client';
import { ChevronUp, MessageCircleQuestion, TriangleAlert } from 'lucide-react';
import type { Agents } from 'librechat-data-provider';
import { splitOtherOption, ASK_USER_DECLINED_ANSWER } from '~/utils/approval';
import { useAskSubmitStatus, useResumeSubmit } from './ApprovalContext';
import useAskAnswerMode from '~/hooks/Input/useAskAnswerMode';
import AskOptions from '~/components/Chat/ask/options';
import { ChatContext } from '~/Providers/ChatContext';
import { splitOtherOption } from '~/utils/approval';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
/**
* Renders an `ask_user_question` pause: the prompt, optional description, any
@ -49,10 +51,8 @@ export default function AskUserQuestion({
);
const status = getAskStatus(actionId);
if (popoverVisible && isLivePause) {
return null;
}
const locked = status === 'submitting' || status === 'submitted' || status === 'expired';
const showPlaceholder = popoverVisible && isLivePause;
if (status === 'submitted') {
return null;
@ -88,6 +88,18 @@ export default function AskUserQuestion({
}
};
/** `answerMode.skip()` is gated on answer mode being ACTIVE, which a
* dismissed (×'d) question is not and that is precisely when this card
* is the only surface left. Decline through the answer path instead,
* which is gated on the live pause rather than on answer mode. */
const handleSkip = () => {
if (isLivePause) {
answerMode.submitAnswer([ASK_USER_DECLINED_ANSWER]);
return;
}
submitAskAnswer(actionId, ASK_USER_DECLINED_ANSWER);
};
const submitCombined = () => {
const values = multiSelect
? checkedIndices
@ -107,46 +119,61 @@ export default function AskUserQuestion({
submitAskAnswer(actionId, values.join(', '));
};
return (
<div className="my-2 flex w-full flex-col gap-2 rounded-lg border border-border-light bg-surface-secondary p-3">
/**
* The live card shares its view-transition-name with the popover panel, so
* collapse/expand morphs one surface into the other. The placeholder copy
* is `visibility: hidden` (out of the tab order and the a11y tree) and
* carries NO transition name duplicate names would void the morph but
* it still occupies the card's exact footprint, so the thread reserves the
* space while the question lives in the composer and nothing reflows when
* it moves back.
*/
const card = (
<div
className={cn(
'my-2 flex w-full flex-col gap-2.5 rounded-xl border border-border-light bg-surface-secondary p-3',
showPlaceholder && 'invisible',
)}
aria-hidden={showPlaceholder || undefined}
style={isLivePause && !showPlaceholder ? { viewTransitionName: 'ask-question' } : undefined}
>
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 text-sm font-medium text-text-primary [overflow-wrap:anywhere]">
{question.question}
</p>
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary [overflow-wrap:anywhere]">
{question.question}
</p>
{question.description != null && question.description.length > 0 && (
<p className="mt-0.5 text-xs text-text-secondary [overflow-wrap:anywhere]">
{question.description}
</p>
)}
</div>
{collapsed && isLivePause && (
<button
type="button"
aria-label={localize('com_ui_expand')}
className="rounded p-1 text-text-secondary hover:bg-surface-hover"
onClick={expand}
>
<ChevronUp className="h-4 w-4" aria-hidden="true" />
</button>
<TooltipAnchor
description={localize('com_ui_ask_move_to_composer')}
side="top"
render={
<button
type="button"
aria-label={localize('com_ui_ask_move_to_composer')}
className="rounded-md p-1 text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
onClick={expand}
>
<ChevronUp className="size-4" aria-hidden="true" />
</button>
}
/>
)}
</div>
{question.description != null && question.description.length > 0 && (
<p className="text-sm text-text-secondary [overflow-wrap:anywhere]">
{question.description}
</p>
)}
{choices.length > 0 && (
<div className="flex flex-wrap gap-2">
{choices.map((option, index) => (
<Button
key={option.value}
size="sm"
variant={multiSelect && checkedIndices.includes(index) ? 'submit' : 'outline'}
role={multiSelect ? 'checkbox' : undefined}
aria-checked={multiSelect ? checkedIndices.includes(index) : undefined}
disabled={locked}
className="h-auto min-h-9 max-w-full whitespace-normal py-1.5 text-left [overflow-wrap:anywhere]"
onClick={() => (multiSelect ? toggleIndex(index) : submitSingle(index))}
>
{option.label}
</Button>
))}
</div>
<AskOptions
options={choices}
multiSelect={multiSelect}
checked={checkedIndices}
locked={locked}
onActivate={(index) => (multiSelect ? toggleIndex(index) : submitSingle(index))}
/>
)}
<TextareaAutosize
@ -156,26 +183,48 @@ export default function AskUserQuestion({
minRows={2}
maxRows={12}
placeholder={otherLabel ?? localize('com_ui_your_answer')}
className="w-full resize-none rounded-md border border-border-light bg-surface-primary p-2 text-sm"
className="w-full resize-none rounded-lg border border-border-light bg-surface-chat px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
aria-label={localize('com_ui_your_answer')}
/>
<div className="flex items-center gap-3">
<Button size="sm" variant="submit" disabled={!canSubmit || locked} onClick={submitCombined}>
<Button size="sm" variant="outline" disabled={locked} onClick={handleSkip}>
{localize('com_ui_skip')}
</Button>
{(status === 'expired' || status === 'error') && (
<span className="flex min-w-0 items-center text-xs text-text-warning">
<TriangleAlert className="mr-1.5 size-4 shrink-0" aria-hidden="true" />
{localize(status === 'expired' ? 'com_ui_approval_expired' : 'com_ui_approval_error')}
</span>
)}
<Button
size="sm"
variant="submit"
className="ml-auto"
disabled={!canSubmit || locked}
onClick={submitCombined}
>
{status === 'submitting' ? localize('com_ui_submitting') : localize('com_ui_submit')}
</Button>
{status === 'expired' && (
<span className="flex items-center text-xs text-text-warning">
<TriangleAlert className="mr-1.5 h-4 w-4" aria-hidden="true" />
{localize('com_ui_approval_expired')}
</span>
)}
{status === 'error' && (
<span className="flex items-center text-xs text-text-warning">
<TriangleAlert className="mr-1.5 h-4 w-4" aria-hidden="true" />
{localize('com_ui_approval_error')}
</span>
)}
</div>
</div>
);
if (!showPlaceholder) {
return card;
}
/** Popover has the question: the reserved card sits hidden underneath the
* same compact in-progress row the other tools use, so the turn still
* shows the call is running. */
return (
<div className="relative">
{card}
<div className="absolute inset-x-0 top-0 my-1 flex h-5 items-center gap-2.5">
<MessageCircleQuestion className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
<span className="tool-status-text shimmer font-medium text-text-secondary">
{localize('com_ui_asking')}
</span>
</div>
</div>
);

View file

@ -0,0 +1,76 @@
import { Check } from 'lucide-react';
import { cn } from '~/utils';
export interface AskOption {
label: string;
value: string;
}
/**
* The single option-row list shared by every `ask_user_question` surface
* (composer popover and chat card), so the same question looks and behaves
* identically wherever it renders. Numbered badges double as the popover's
* digit shortcuts; multi-select swaps the number for a check.
*/
export default function AskOptions({
options,
multiSelect,
checked,
selected,
locked,
onActivate,
optionRefs,
listRef,
className,
}: {
options: AskOption[];
multiSelect: boolean;
checked: number[];
selected?: number | null;
locked: boolean;
onActivate: (index: number) => void;
optionRefs?: React.MutableRefObject<(HTMLButtonElement | null)[]>;
listRef?: React.RefObject<HTMLDivElement>;
className?: string;
}) {
return (
<div ref={listRef} className={className}>
{options.map((option, index) => {
const isChecked = multiSelect && checked.includes(index);
return (
<button
key={option.value}
ref={(el) => {
if (optionRefs) {
optionRefs.current[index] = el;
}
}}
type="button"
role={multiSelect ? 'checkbox' : undefined}
aria-checked={multiSelect ? isChecked : undefined}
disabled={locked}
className={cn(
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm text-text-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
selected === index ? 'bg-surface-active' : 'hover:bg-surface-hover',
locked && 'cursor-not-allowed opacity-60',
)}
onClick={() => onActivate(index)}
>
<span
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-medium tabular-nums transition-colors',
isChecked
? 'bg-surface-submit text-white'
: 'bg-surface-tertiary text-text-secondary',
)}
>
{isChecked ? <Check className="size-3.5" aria-hidden="true" /> : index + 1}
</span>
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">{option.label}</span>
</button>
);
})}
</div>
);
}

View file

@ -872,6 +872,8 @@
"com_ui_artifacts_subtext": "Lets the agent render React, HTML, SVG, Markdown, and Mermaid as interactive artifacts in a side panel instead of plain code blocks.",
"com_ui_ascending": "Asc",
"com_ui_ask_answer_error": "Your answer couldn't be sent. Try again.",
"com_ui_ask_move_to_chat": "Answer later in the chat",
"com_ui_ask_move_to_composer": "Answer from the message box",
"com_ui_ask_type_below": "Or type your answer below",
"com_ui_ask_type_below_only": "Type your answer below",
"com_ui_ask_user": "Ask User",