mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
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:
parent
8320943ba5
commit
ef251a481d
4 changed files with 213 additions and 113 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
76
client/src/components/Chat/ask/options.tsx
Normal file
76
client/src/components/Chat/ask/options.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue