mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-12 01:03:45 +00:00
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:
parent
b8e40543c6
commit
39760e534e
3 changed files with 170 additions and 123 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
141
client/src/hooks/Input/useAskAnswerMode.ts
Normal file
141
client/src/hooks/Input/useAskAnswerMode.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue