diff --git a/client/src/components/Chat/Input/AskUserQuestionPopover.tsx b/client/src/components/Chat/Input/AskUserQuestionPopover.tsx index e8df357cd8..f295b9482a 100644 --- a/client/src/components/Chat/Input/AskUserQuestionPopover.tsx +++ b/client/src/components/Chat/Input/AskUserQuestionPopover.tsx @@ -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({ - 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; -}) { +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)} > {index + 1} @@ -156,17 +67,13 @@ function AskUserQuestionPopoverContent({ const AskUserQuestionPopover = memo(function AskUserQuestionPopover({ conversationId, - textAreaRef, }: { conversationId?: string | null; - textAreaRef: React.MutableRefObject; }) { if (conversationId == null || conversationId === 'new') { return null; } - return ( - - ); + return ; }); export default AskUserQuestionPopover; diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index a2a2ad8fec..89c6661be8 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -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 (
{ - // 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} /> - {index === 0 && ( - - )} + {index === 0 && } { + // 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} diff --git a/client/src/hooks/Input/useAskAnswerMode.ts b/client/src/hooks/Input/useAskAnswerMode.ts new file mode 100644 index 0000000000..553c1291d7 --- /dev/null +++ b/client/src/hooks/Input/useAskAnswerMode.ts @@ -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({ + key: 'askAnswerModeDismissedActions', + default: [], +}); + +/** Highlighted option row, shared between the popover (render) and the + * composer's key handling (navigation). */ +const askAnswerActiveIndexAtom = atom({ + 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): 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, + }; +}