feat: the composer is the free-form answer box (like the main chat input)

While a question pause is live, the main chat textarea composes the free-form
answer — placeholder swaps to 'Something else…' (or a folded model 'Other'
label), Enter with text submits the answer through answer-mode key handling
(composed BEFORE useTextarea's submitting-lock, so the lock can't swallow it),
and the Stop button swaps to Send (enabled despite isSubmitting) per the
select-then-confirm design. The popover slims to the question header, numbered
option rows, and Skip/Submit — its inline input is gone since the composer
owns free-form now. Dismissing the popover restores normal composer semantics
(Stop button, normal sends).
This commit is contained in:
Danny Avila 2026-07-07 11:38:54 -04:00
parent a92760ad05
commit b215560d97
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
3 changed files with 49 additions and 55 deletions

View file

@ -14,19 +14,8 @@ import { cn } from '~/utils';
*/
function AskUserQuestionPopoverContent({ conversationId }: { conversationId: string }) {
const localize = useLocalize();
const {
liveAsk,
active,
options,
selected,
setSelected,
otherText,
setOtherText,
canSubmit,
submit,
dismiss,
otherLabel,
} = useAskAnswerMode(conversationId);
const { liveAsk, active, options, selected, setSelected, canSubmit, submit, dismiss } =
useAskAnswerMode(conversationId);
if (!active || !liveAsk) {
return null;
@ -71,42 +60,6 @@ function AskUserQuestionPopoverContent({ conversationId }: { conversationId: str
<span className="flex-1">{option.label}</span>
</button>
))}
<div
className={cn(
'flex w-full items-center gap-3 rounded-lg p-2 text-sm',
selected === 'other' ? 'bg-surface-active' : 'hover:bg-surface-hover',
)}
>
{options.length > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded bg-surface-tertiary text-xs text-text-secondary">
{options.length + 1}
</span>
)}
<input
value={otherText}
onChange={(e) => {
setOtherText(e.target.value);
setSelected('other');
}}
onFocus={() => setSelected('other')}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSubmit) {
e.preventDefault();
submit();
} else if (e.key === 'Escape') {
dismiss();
}
}}
placeholder={
otherLabel ??
(options.length > 0
? localize('com_ui_something_else')
: localize('com_ui_your_answer'))
}
className="flex-1 border-0 bg-transparent text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
aria-label={localize('com_ui_your_answer')}
/>
</div>
<div className="flex items-center justify-end gap-2 p-2">
<Button size="sm" variant="outline" onClick={dismiss}>
{localize('com_ui_skip')}

View file

@ -191,7 +191,10 @@ const ChatForm = memo(function ChatForm({
submitButtonRef,
setIsScrollable,
disabled: disableInputs,
placeholder,
// The composer IS the free-form answer box while a question pause is live.
placeholder: answerMode.active
? (answerMode.otherLabel ?? localize('com_ui_something_else'))
: placeholder,
});
useQueryParams({ textAreaRef });
@ -248,7 +251,15 @@ const ChatForm = memo(function ChatForm({
return (
<form
onSubmit={methods.handleSubmit(submitMessage)}
onSubmit={methods.handleSubmit((data) => {
// Answer mode: composer text answers the paused run instead of
// starting a new turn. Dismissing the popover restores normal sends.
if (answerMode.active && answerMode.submitText(data.text)) {
methods.reset();
return;
}
return submitMessage(data);
})}
className={cn(
'mx-auto flex w-full flex-row gap-3 transition-[max-width] duration-300 sm:px-2',
maximizeChatSpace ? 'max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
@ -412,14 +423,19 @@ const ChatForm = memo(function ChatForm({
/>
)}
<div className={`${isRTL ? 'ml-2' : 'mr-2'}`}>
{isSubmitting && showStopButton ? (
{isSubmitting && showStopButton && !answerMode.active ? (
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
) : (
endpoint && (
<SendButton
ref={submitButtonRef}
control={methods.control}
disabled={filesLoading || isSubmitting || disableInputs || isNotAppendable}
disabled={
filesLoading ||
disableInputs ||
isNotAppendable ||
(isSubmitting && !answerMode.active)
}
/>
)
)}

View file

@ -88,13 +88,37 @@ export default function useAskAnswerMode(conversationId?: string | null) {
setOtherText,
]);
/** Composer text answers the question directly; true when consumed. */
const submitText = useCallback(
(text: string): boolean => {
if (!active || !liveAsk) {
return false;
}
const trimmed = text.trim();
if (trimmed.length > 0) {
submitAskAnswer(liveAsk.actionId, trimmed);
setSelected(null);
setOtherText('');
}
return true;
},
[active, liveAsk, submitAskAnswer, setSelected, setOtherText],
);
/** Selection steering from the empty composer; true when consumed. */
const handleComposerKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean => {
if (!active) {
return false;
}
if (e.currentTarget.value.trim().length > 0) {
const composerText = e.currentTarget.value;
if (composerText.trim().length > 0) {
// The composer IS the free-form answer box: Enter submits the typed
// text (before useTextarea's submitting-lock can swallow it).
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
return submitText(composerText);
}
return false;
}
const rowCount = options.length + 1; // + the inline Other row
@ -129,7 +153,7 @@ export default function useAskAnswerMode(conversationId?: string | null) {
}
return false;
},
[active, options, selected, canSubmit, submit, dismiss, setSelected],
[active, options, selected, canSubmit, submit, submitText, dismiss, setSelected],
);
return {
@ -144,6 +168,7 @@ export default function useAskAnswerMode(conversationId?: string | null) {
setOtherText,
canSubmit,
submit,
submitText,
handleComposerKeyDown,
/** Model-supplied "Other"-style label, folded into the inline input. */
otherLabel,