refactor: first-class composer answer mode (useAskAnswerMode)

Replaces the bolted-on integration (inline onSubmit interception + raw
capture-phase keydown listeners on the textarea ref) with a single hook that
owns the whole answer mode: live-question derivation, dismissal + highlighted
option (shared recoil state), option selection, free-form submit routing
(submitText returns whether it consumed the submission), and keyboard
handling (handleKeyDown returns whether it consumed the key, composed ahead
of the textarea's normal handler — no more addEventListener).

The popover is now pure rendering off the hook; ChatForm wires placeholder,
onKeyDown, and onSubmit through the same instance. Deliberately scoped to the
composer rather than useSubmitMessage: starters/prompt-commands keep new-turn
semantics (and the existing job-replacement behavior while paused).
This commit is contained in:
Danny Avila 2026-07-07 09:51:06 -04:00
parent b8e40543c6
commit 39760e534e
3 changed files with 170 additions and 123 deletions

View file

@ -1,112 +1,23 @@
import { memo, useEffect, useMemo, useState } from 'react';
import { memo } from 'react';
import { CornerDownLeft, X } from 'lucide-react';
import { atom, useRecoilState } from 'recoil';
import { useResumeSubmit } from '../Messages/Content/ApprovalContext';
import { findLiveAskUserQuestion } from '~/utils/approval';
import { useGetMessagesByConvoId } from '~/data-provider';
import useAskAnswerMode from '~/hooks/Input/useAskAnswerMode';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
/** Action ids the user closed the popover for session-scoped; the inline
* question card in the transcript remains the fallback answer surface. Recoil
* so ChatForm (placeholder + submit routing) reacts to a dismissal too. */
const dismissedAskActionsAtom = atom<string[]>({
key: 'askUserQuestionDismissedActions',
default: [],
});
/**
* Shared derivation for the composer's ask-question integration: the live
* (unanswered, undismissed) question for a conversation. Used by both the
* popover and ChatForm (placeholder override + answer-routing submit).
*/
export function useLiveAskUserQuestion(conversationId?: string | null) {
const enabled = conversationId != null && conversationId !== 'new';
const { data: messagesTree } = useGetMessagesByConvoId(enabled ? conversationId : '', {
enabled,
});
const liveAsk = useMemo(
() => (enabled ? findLiveAskUserQuestion(messagesTree) : null),
[enabled, messagesTree],
);
const [dismissedIds, setDismissedIds] = useRecoilState(dismissedAskActionsAtom);
const dismissed = liveAsk != null && dismissedIds.includes(liveAsk.actionId);
const dismiss = () => {
if (liveAsk && !dismissedIds.includes(liveAsk.actionId)) {
setDismissedIds((prev) => [...prev, liveAsk.actionId]);
}
};
return { liveAsk, dismissed, dismiss };
}
/**
* Composer popover for a live `ask_user_question` pause, matching the
* mentions/prompts popovers: the question as a header, numbered option rows
* (/ + Enter from the composer while it's empty), and the main textarea as
* the free-form answer ("Something else…"). Renders exactly while the pause's
* synthetic content part exists and disappears the moment an answer submits.
* mentions/prompts popovers. Pure rendering: all state (live question,
* dismissal, option highlight, selection) lives in {@link useAskAnswerMode},
* which the composer shares for placeholder/keyboard/submit routing. Renders
* exactly while the pause's synthetic content part exists and disappears the
* moment an answer submits from any surface.
*/
function AskUserQuestionPopoverContent({
conversationId,
textAreaRef,
}: {
conversationId: string;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
}) {
function AskUserQuestionPopoverContent({ conversationId }: { conversationId: string }) {
const localize = useLocalize();
const { liveAsk, dismissed, dismiss } = useLiveAskUserQuestion(conversationId);
const { submitAskAnswer } = useResumeSubmit();
const [activeIndex, setActiveIndex] = useState(0);
const { liveAsk, active, options, activeIndex, setActiveIndex, selectOption, dismiss } =
useAskAnswerMode(conversationId);
const options = useMemo(() => liveAsk?.question.options ?? [], [liveAsk]);
useEffect(() => setActiveIndex(0), [liveAsk?.actionId]);
useEffect(() => {
if (!liveAsk || dismissed || options.length === 0) {
return;
}
const textarea = textAreaRef.current;
if (!textarea) {
return;
}
const onKeyDown = (e: KeyboardEvent) => {
// Only steer the option list while the composer is empty — otherwise the
// keys belong to the text the user is typing as a free-form answer.
if (textarea.value.trim().length > 0) {
return;
}
// Number keys pick the matching option directly (1-9), mirroring the
// row chips — same empty-composer guard as the arrow keys.
const digit = Number.parseInt(e.key, 10);
if (!Number.isNaN(digit) && digit >= 1 && digit <= Math.min(options.length, 9)) {
e.preventDefault();
e.stopPropagation();
submitAskAnswer(liveAsk.actionId, options[digit - 1].value);
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((prev) => (prev + 1) % options.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((prev) => (prev - 1 + options.length) % options.length);
} else if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
const option = options[activeIndex];
if (option) {
submitAskAnswer(liveAsk.actionId, option.value);
}
} else if (e.key === 'Escape') {
dismiss();
}
};
textarea.addEventListener('keydown', onKeyDown, { capture: true });
return () => textarea.removeEventListener('keydown', onKeyDown, { capture: true });
}, [liveAsk, dismissed, options, activeIndex, submitAskAnswer, textAreaRef, dismiss]);
if (!liveAsk || dismissed) {
if (!active || !liveAsk) {
return null;
}
@ -138,7 +49,7 @@ function AskUserQuestionPopoverContent({
index === activeIndex ? 'bg-surface-active' : 'hover:bg-surface-hover',
)}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => submitAskAnswer(liveAsk.actionId, option.value)}
onClick={() => selectOption(index)}
>
<span className="flex h-5 w-5 items-center justify-center rounded bg-surface-tertiary text-xs text-text-secondary">
{index + 1}
@ -156,17 +67,13 @@ function AskUserQuestionPopoverContent({
const AskUserQuestionPopover = memo(function AskUserQuestionPopover({
conversationId,
textAreaRef,
}: {
conversationId?: string | null;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
}) {
if (conversationId == null || conversationId === 'new') {
return null;
}
return (
<AskUserQuestionPopoverContent conversationId={conversationId} textAreaRef={textAreaRef} />
);
return <AskUserQuestionPopoverContent conversationId={conversationId} />;
});
export default AskUserQuestionPopover;

View file

@ -21,9 +21,9 @@ import {
useAddedChatContext,
useAssistantsMapContext,
} from '~/Providers';
import AskUserQuestionPopover, { useLiveAskUserQuestion } from './AskUserQuestionPopover';
import { useResumeSubmit } from '../Messages/Content/ApprovalContext';
import PendingManualSkillsChips from './PendingManualSkillsChips';
import useAskAnswerMode from '~/hooks/Input/useAskAnswerMode';
import AskUserQuestionPopover from './AskUserQuestionPopover';
import { cn, getModelSpec, removeFocusRings } from '~/utils';
import { useGetStartupConfig } from '~/data-provider';
import { mainTextareaId, BadgeItem } from '~/common';
@ -174,9 +174,7 @@ const ChatForm = memo(function ChatForm({
});
const { submitMessage, submitPrompt } = useSubmitMessage();
const { liveAsk, dismissed: askDismissed } = useLiveAskUserQuestion(conversationId);
const { submitAskAnswer } = useResumeSubmit();
const askActive = liveAsk != null && !askDismissed;
const answerMode = useAskAnswerMode(conversationId);
const handleKeyUp = useHandleKeyUp({
index,
@ -195,7 +193,7 @@ const ChatForm = memo(function ChatForm({
disabled: disableInputs,
// While a question pause is live, the composer doubles as the free-form
// answer box (the popover's option list covers the curated choices).
placeholder: askActive ? localize('com_ui_something_else') : placeholder,
placeholder: answerMode.active ? localize('com_ui_something_else') : placeholder,
});
useQueryParams({ textAreaRef });
@ -253,15 +251,11 @@ const ChatForm = memo(function ChatForm({
return (
<form
onSubmit={methods.handleSubmit((data) => {
// A live question routes the composer's text to the paused run as the
// Answer mode routes the composer's text to the paused run as the
// free-form answer instead of starting a new turn (which would replace
// the paused job). Dismissing the popover restores normal sends.
if (askActive && liveAsk) {
const text = data.text.trim();
if (text.length > 0) {
submitAskAnswer(liveAsk.actionId, text);
methods.reset();
}
if (answerMode.submitText(data.text)) {
methods.reset();
return;
}
return submitMessage(data);
@ -297,9 +291,7 @@ const ChatForm = memo(function ChatForm({
textAreaRef={textAreaRef}
/>
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
{index === 0 && (
<AskUserQuestionPopover conversationId={conversationId} textAreaRef={textAreaRef} />
)}
{index === 0 && <AskUserQuestionPopover conversationId={conversationId} />}
<SkillsCommand
index={index}
textAreaRef={textAreaRef}
@ -354,7 +346,14 @@ const ChatForm = memo(function ChatForm({
}}
disabled={disableInputs || isNotAppendable}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyDown={(e) => {
// Answer mode consumes option-navigation keys from the
// empty composer; everything else follows the normal path.
if (answerMode.handleKeyDown(e)) {
return;
}
handleKeyDown(e);
}}
onKeyUp={handleKeyUp}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}

View file

@ -0,0 +1,141 @@
import { useCallback, useMemo } from 'react';
import { atom, useRecoilState } from 'recoil';
import { useResumeSubmit } from '~/components/Chat/Messages/Content/ApprovalContext';
import { findLiveAskUserQuestion } from '~/utils/approval';
import { useGetMessagesByConvoId } from '~/data-provider';
/** Dismissed action ids — recoil so every consumer (popover, composer) reacts. */
const dismissedAskActionsAtom = atom<string[]>({
key: 'askAnswerModeDismissedActions',
default: [],
});
/** Highlighted option row, shared between the popover (render) and the
* composer's key handling (navigation). */
const askAnswerActiveIndexAtom = atom<number>({
key: 'askAnswerModeActiveIndex',
default: 0,
});
/**
* First-class "answer mode" for the composer. While an `ask_user_question`
* pause is live (its synthetic content part exists and the user hasn't
* dismissed the popover), the composer answers the question instead of
* starting a new turn:
*
* - `handleKeyDown` owns option navigation from the EMPTY composer
* (arrows, 1-9 direct pick, Enter selects, Escape dismisses) and returns
* whether it consumed the event the caller runs its normal key handling
* only when it didn't;
* - `submitText` routes non-empty composer text to the paused run as the
* free-form answer and reports whether it consumed the submission;
* - `active` drives the placeholder swap ("Something else…").
*
* Deliberately scoped to the composer (not `useSubmitMessage`): other submit
* surfaces (conversation starters, prompt commands) send canned prompts whose
* meaning is "new turn", so they keep normal semantics which also means
* they inherit the existing job-replacement behavior while paused.
*/
export default function useAskAnswerMode(conversationId?: string | null) {
const enabled = conversationId != null && conversationId !== 'new';
const { data: messages } = useGetMessagesByConvoId(enabled ? conversationId : '', {
enabled,
});
const liveAsk = useMemo(
() => (enabled ? findLiveAskUserQuestion(messages) : null),
[enabled, messages],
);
const [dismissedIds, setDismissedIds] = useRecoilState(dismissedAskActionsAtom);
const [activeIndex, setActiveIndex] = useRecoilState(askAnswerActiveIndexAtom);
const { submitAskAnswer } = useResumeSubmit();
const dismissed = liveAsk != null && dismissedIds.includes(liveAsk.actionId);
const active = liveAsk != null && !dismissed;
const options = useMemo(() => liveAsk?.question.options ?? [], [liveAsk]);
const dismiss = useCallback(() => {
if (liveAsk) {
setDismissedIds((prev) =>
prev.includes(liveAsk.actionId) ? prev : [...prev, liveAsk.actionId],
);
}
}, [liveAsk, setDismissedIds]);
const selectOption = useCallback(
(index: number) => {
const option = options[index];
if (liveAsk && option) {
submitAskAnswer(liveAsk.actionId, option.value);
}
},
[liveAsk, options, submitAskAnswer],
);
/** Non-empty composer text answers the question; returns 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);
}
return true;
},
[active, liveAsk, submitAskAnswer],
);
/** Option navigation from the composer; returns true when it consumed the key. */
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean => {
if (!active || options.length === 0) {
return false;
}
// Keys belong to the user's text once they start typing a free-form answer.
if (e.currentTarget.value.trim().length > 0) {
return false;
}
const digit = Number.parseInt(e.key, 10);
if (!Number.isNaN(digit) && digit >= 1 && digit <= Math.min(options.length, 9)) {
e.preventDefault();
selectOption(digit - 1);
return true;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((prev) => (prev + 1) % options.length);
return true;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((prev) => (prev - 1 + options.length) % options.length);
return true;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
selectOption(activeIndex);
return true;
}
if (e.key === 'Escape') {
dismiss();
return true;
}
return false;
},
[active, options, activeIndex, selectOption, setActiveIndex, dismiss],
);
return {
active,
liveAsk,
options,
dismissed,
dismiss,
activeIndex,
setActiveIndex,
selectOption,
submitText,
handleKeyDown,
};
}