LibreChat/client/src/hooks/Input/useAskAnswerMode.ts
2026-08-23 21:48:34 +02:00

537 lines
20 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef } from 'react';
import { atom, useRecoilState, useRecoilValue } from 'recoil';
import {
useAskSubmitStatus,
useResumeSubmit,
} from '~/components/Chat/Messages/Content/ApprovalContext';
import {
ASK_USER_DECLINED_ANSWER,
findLiveAskUserQuestion,
splitOtherOption,
} from '~/utils/approval';
import { getAskAnswerDraftId, morphTransition, setDraft } from '~/utils';
import { useGetMessagesByConvoId } from '~/data-provider';
import { useOptionalChatFormContext } from '~/Providers';
import store from '~/store';
/**
* Action ids the user moved into the chat: the popover is hidden AND the
* composer is released (answer mode off), so the chat card is the question's
* only surface until the card's chevron moves it back. One state for one
* user-visible concept — a hidden popover whose composer stayed armed was
* indistinguishable from one whose composer did not.
*/
const collapsedAskActionsAtom = atom<string[]>({
key: 'askAnswerModeCollapsedActions',
default: [],
});
/** Currently highlighted option row (keyboard cursor), or nothing. */
const askAnswerSelectionAtom = atom<number | null>({
key: 'askAnswerModeSelection',
default: null,
});
/** Checked option rows for a multi-select question — shared across the
* popover, the composer, and the chat card so every surface shows (and
* submits) the same set. */
const askAnswerCheckedAtom = atom<number[]>({
key: 'askAnswerModeChecked',
default: [],
});
/** Free-form answer handed between the composer and the in-message card. */
const askAnswerTextAtom = atom<{ actionId: string | null; text: string }>({
key: 'askAnswerModeText',
default: { actionId: null, text: '' },
});
/**
* First-class "answer mode" for a live `ask_user_question` pause. Clicking an
* option submits it immediately (multi-select clicks toggle instead, confirmed
* by an explicit Submit); arrows/digits from the EMPTY composer steer a
* keyboard highlight that Enter fires — but only while the popover is visible,
* since it is the only surface that renders the highlight. The composer IS the
* free-form answer box — its placeholder swaps, Enter submits the typed text,
* and its autosave drafts under `draftId` (the question's own key) so the
* conversation draft is stashed on entry and restored once the question
* resolves.
*
* One way out short of answering: `collapse` (the popover's chevron, or
* Escape) moves the question to the chat card AND releases the composer, so
* the user can type a normal message; the card's chevron moves it back.
* `Skip` resumes the run with a canned decline notice.
*
* `handleComposerKeyDown` only steers selection from the EMPTY composer and
* reports whether it consumed the key.
*/
export default function useAskAnswerMode(conversationId?: string | null) {
const enabled = conversationId != null && conversationId !== 'new';
/** `select` projects straight to the live pause: streaming deltas leave the
* settled ask part untouched, so structural sharing keeps this null (or the
* same ask object) and the subscription stays quiet until a pause actually
* starts or resolves. */
const { data: liveAskData } = useGetMessagesByConvoId(enabled ? conversationId : '', {
enabled,
select: findLiveAskUserQuestion,
});
const liveAsk = enabled ? (liveAskData ?? null) : null;
const [collapsedIds, setCollapsedIds] = useRecoilState(collapsedAskActionsAtom);
const [selected, setSelected] = useRecoilState(askAnswerSelectionAtom);
const [checked, setChecked] = useRecoilState(askAnswerCheckedAtom);
const [answerDraft, setAnswerDraft] = useRecoilState(askAnswerTextAtom);
const saveDrafts = useRecoilValue<boolean>(store.saveDrafts);
const { submitAskAnswer } = useResumeSubmit();
/** Recoil-backed so the lock/status works from the composer, which renders
* outside `ApprovalProvider` (where the context status would be inert). */
const { getAskStatus } = useAskSubmitStatus();
/** Absent outside ChatView (Share/search render the answer card without the
* composer form) — resets are simply skipped there. */
const formContext = useOptionalChatFormContext();
/** Resume callbacks may settle after this ChatForm has navigated to another
* conversation (the form instance is intentionally reused across routes).
* Keep the callback's current ownership observable without letting its old
* closure reset a newer draft or selection. */
const currentScopeRef = useRef({
conversationId,
actionId: liveAsk?.actionId,
formContext,
});
currentScopeRef.current = {
conversationId,
actionId: liveAsk?.actionId,
formContext,
};
const mountedRef = useRef(true);
useEffect(() => {
// Strict Mode runs setup, cleanup, then setup again in development; each
// live setup must reassert ownership before a success callback can clean.
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
/** The answer is in flight (or terminal): every submit path must become a
* no-op so a double-click or a stray Skip can't race a second resume. */
const status = liveAsk != null ? getAskStatus(liveAsk.actionId) : 'idle';
const locked = status === 'submitting' || status === 'submitted' || status === 'expired';
/**
* An EXPIRED question can no longer be answered, so it drops out of answer
* mode entirely: the popover closes, the composer reverts to a normal
* composer, and the chat card (always mounted for the live pause) becomes
* the sole surface — it carries the only "this action expired" message, so
* suppressing it behind an open popover would strand the user at a locked
* card with no explanation. (`error`, unlike `expired`, stays active: it is
* retryable — see the composer-preserving submit path.)
*
* A `submitted` question is likewise done — the run has resumed and there is
* nothing left to answer. The chat card already self-hides on it; without the
* same test here a card that outlives its strip (a resurrected copy, or a
* submit whose store write couldn't run) holds the popover open over an
* answered question with every option greyed out.
*/
const answerable = liveAsk != null && status !== 'expired' && status !== 'submitted';
/** Moved to the chat: the card owns the question and the composer is free. */
const collapsed = answerable && collapsedIds.includes(liveAsk.actionId);
/**
* Answer mode: the popover is up AND the composer is the free-form answer
* box. The two are deliberately the same condition — the composer's answer
* role is only discoverable while the popover explains it.
*/
const active = answerable && !collapsed;
const popoverVisible = active;
const batchMode = (liveAsk?.questions?.length ?? 0) > 0;
const composerAnswers = active && !batchMode;
const composerLocked = popoverVisible && batchMode;
const multiSelect = !batchMode && liveAsk != null && liveAsk.question.multiSelect === true;
/** Answer-phase draft key: handed to useAutoSave so the composer drafts
* under the question's own key while answer mode is live, leaving the
* conversation draft untouched until the swap-back restores it. */
const draftId =
active && liveAsk != null && !batchMode ? getAskAnswerDraftId(liveAsk.actionId) : null;
const { choices: options, otherLabel } = useMemo(
() => splitOtherOption(batchMode ? undefined : liveAsk?.question.options),
[batchMode, liveAsk],
);
const answerText = answerDraft.actionId === liveAsk?.actionId ? answerDraft.text : '';
const setAnswerText = useCallback(
(text: string) => {
if (liveAsk) {
setAnswerDraft({ actionId: liveAsk.actionId, text });
/** While the card owns the answer, `useAutoSave` is tracking the
* conversation draft instead. Keep the dormant ask draft current so
* expanding can restore this edit without clobbering that message. */
if (saveDrafts) {
setDraft({ id: getAskAnswerDraftId(liveAsk.actionId), value: text });
}
}
},
[liveAsk, saveDrafts, setAnswerDraft],
);
/** Selection state is per-question: a new pause must never inherit a stale
* highlight (or checks) whose Enter would submit the previous question's
* choice. */
useEffect(() => {
setSelected(null);
setChecked([]);
}, [liveAsk?.actionId, setSelected, setChecked]);
/** Popover ⇄ chat-card handoffs run inside a view transition: both
* surfaces carry the same `view-transition-name`, so the browser morphs
* one into the other instead of swapping. Both are user-event driven,
* which morphTransition's synchronous flush requires. */
const collapse = useCallback(() => {
if (liveAsk) {
const composerAnswer = formContext?.getValues('text') ?? answerText;
morphTransition(() => {
setAnswerDraft({ actionId: liveAsk.actionId, text: composerAnswer });
setCollapsedIds((prev) =>
prev.includes(liveAsk.actionId) ? prev : [...prev, liveAsk.actionId],
);
});
}
}, [liveAsk, formContext, answerText, setAnswerDraft, setCollapsedIds]);
const expand = useCallback(() => {
if (liveAsk) {
morphTransition(() => {
/** Autosave restores the ask-specific draft after the key switch. If
* drafts are disabled, perform that handoff directly. */
if (!saveDrafts) {
formContext?.setValue('text', answerText);
}
setCollapsedIds((prev) => prev.filter((id) => id !== liveAsk.actionId));
});
}
}, [liveAsk, saveDrafts, formContext, answerText, setCollapsedIds]);
/** Pure check toggle: the keyboard highlight is steered only by the
* composer's digit/arrow shortcuts, so a mouse toggle never leaves a
* row painted `selected` after it is unchecked. */
const toggleChecked = useCallback(
(index: number) => {
setChecked((prev) =>
prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index],
);
},
[setChecked],
);
const canSubmit =
active &&
!locked &&
(multiSelect ? checked.length > 0 : typeof selected === 'number' && options[selected] != null);
/**
* Shared answer dispatch: sends the run's resume and clears the phase.
* Gated on the live pause (NOT `active`, because the chat card must still
* answer a collapsed question) and on `locked` (no duplicate resumes while one is in
* flight).
*
* The selection/composer cleanup runs ONLY after the resume is accepted (in
* `submitAskAnswer`'s success path): a failed resume — the 16k answer-cap
* 400, an expired action, a network error — leaves `status` re-answerable,
* so wiping the composer (the user's only copy of a free-form answer) up
* front would lose it. The composer only resets when its text was consumed
* by the answer or when the draft machinery will restore the stashed
* conversation draft; with drafts disabled and an option-click answer the
* typed text is left alone.
*/
const submitValues = useCallback(
(values: string[], consumedComposerText = false): boolean => {
if (!liveAsk || locked || values.length === 0) {
return false;
}
const wasActive = active;
const submittedConversationId = conversationId;
const submittedActionId = liveAsk.actionId;
const submittedComposerText = formContext?.getValues('text') ?? '';
submitAskAnswer(liveAsk.actionId, values.join(', '), {
onSuccess: () => {
const currentScope = currentScopeRef.current;
if (
!mountedRef.current ||
currentScope.conversationId !== submittedConversationId ||
currentScope.actionId !== submittedActionId
) {
return;
}
setSelected(null);
setChecked([]);
setAnswerDraft((current) =>
current.actionId === submittedActionId
? { actionId: submittedActionId, text: '' }
: current,
);
if (
(consumedComposerText || (wasActive && saveDrafts)) &&
currentScope.formContext?.getValues('text') === submittedComposerText
) {
currentScope.formContext.reset();
}
},
});
return true;
},
[
liveAsk,
locked,
active,
saveDrafts,
conversationId,
formContext,
submitAskAnswer,
setSelected,
setChecked,
setAnswerDraft,
],
);
const checkedValues = useCallback(
() =>
checked
.map((index) => options[index]?.value)
.filter((value): value is string => typeof value === 'string'),
[checked, options],
);
/** Single-select click path: one click on an option IS the answer. */
const submitOption = useCallback(
(index: number): boolean => {
const option = options[index];
if (!option) {
return false;
}
return submitValues([option.value]);
},
[options, submitValues],
);
/**
* Confirm path (Enter / multi-select Submit); true when an answer was sent.
* On multi-select any `freeText` (the composer's current value) rides along
* with the checked options — the Submit button must never silently drop
* text the footer hint invited.
*/
const submit = useCallback(
(freeText?: string): boolean => {
const trimmed = freeText?.trim() ?? '';
if (multiSelect) {
const values = checkedValues();
if (trimmed.length > 0) {
values.push(trimmed);
}
return submitValues(values, trimmed.length > 0);
}
if (typeof selected !== 'number') {
return false;
}
return submitOption(selected);
},
[multiSelect, checkedValues, selected, submitValues, submitOption],
);
/**
* Composer text answers the question directly; true when consumed. On a
* multi-select question any checked options ride along with the text.
*
* A batch answers in its card, so the composer's text is none of its
* business: report it UNconsumed and let the normal send/steer path have it.
* Claiming it (the old `return true`) silently swallowed whatever was staged
* when the pause began — the submit reported success and dropped the words.
*/
const submitText = useCallback(
(text: string): boolean => {
if (!active || !liveAsk || batchMode) {
return false;
}
const trimmed = text.trim();
if (trimmed.length > 0) {
submitValues(multiSelect ? [...checkedValues(), trimmed] : [trimmed], true);
}
return true;
},
[active, liveAsk, batchMode, multiSelect, checkedValues, submitValues],
);
/**
* Answer with explicit values from any surface (the chat card's combined
* multi-select + free-text submit). Routed through {@link submitValues} so
* the in-flight guard and composer/draft cleanup apply everywhere.
*/
const submitAnswer = useCallback(
(values: string[]): boolean => submitValues(values),
[submitValues],
);
/**
* Explicitly decline: resumes the run with a canned notice so the model
* knows the user chose not to answer.
*/
const skip = useCallback((): boolean => {
if (!active) {
return false;
}
return submitValues([ASK_USER_DECLINED_ANSWER]);
}, [active, submitValues]);
/** Selection steering from the empty composer; true when consumed. */
const handleComposerKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean => {
if (!active) {
return false;
}
/**
* Let IME composition commit normally: with a CJK keyboard, Enter
* commits the in-progress composition rather than submitting, and the
* composition buffer can leave `value` empty mid-compose — so bail
* before ANY Enter-submit or digit/arrow steering. Mirrors the composer
* guard in `useTextarea` (Safari reports `isComposing` inconsistently,
* hence the `key`/`keyCode` fallbacks); this handler runs first, so the
* guard must live here too.
*/
if (e.nativeEvent.isComposing || e.key === 'Process' || e.keyCode === 229) {
return false;
}
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). Not for
// a batch, which answers in its card — its Enter belongs to the normal
// send path, so leave the event untouched rather than preventDefault
// an event we are about to decline.
if (e.key === 'Enter' && !e.shiftKey && !batchMode) {
e.preventDefault();
return submitText(composerText);
}
return false;
}
/**
* Option steering (digits/arrows/Enter-on-highlight) only while the
* popover — the sole surface that renders the highlight and checks — is
* actually visible. While collapsed, digits must type normally: eating
* the leading "2" of "2pm works" to move an invisible highlight would
* corrupt the free-form answer.
*/
if (options.length === 0 || !popoverVisible) {
if (e.key === 'Escape') {
collapse();
return true;
}
return false;
}
const digit = Number.parseInt(e.key, 10);
if (!Number.isNaN(digit) && digit >= 1 && digit <= Math.min(options.length, 9)) {
e.preventDefault();
if (multiSelect) {
setSelected(digit - 1);
toggleChecked(digit - 1);
} else {
setSelected(digit - 1);
}
return true;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelected(((selected ?? -1) + 1) % options.length);
return true;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelected(((selected ?? 0) - 1 + options.length) % options.length);
return true;
}
if (e.key === 'Enter' && !e.shiftKey && canSubmit) {
e.preventDefault();
submit();
return true;
}
if (e.key === 'Escape') {
collapse();
return true;
}
return false;
},
[
active,
options,
selected,
batchMode,
multiSelect,
popoverVisible,
canSubmit,
submit,
submitText,
toggleChecked,
collapse,
setSelected,
],
);
/**
* Digit shortcuts while the POPOVER itself holds focus (e.g. a row/Skip
* button was clicked or tabbed to). A number activates its option exactly
* like a click — single-select submits, multi toggles — so numbers work no
* matter where focus landed, not only from the empty composer. No
* highlight/Enter dance here: on the popover the options are buttons whose
* action IS the click, and intercepting Enter would fight the focused
* button. Returns whether the key was consumed.
*/
const handlePopoverKeyDown = useCallback(
(e: React.KeyboardEvent): boolean => {
if (!active || locked || options.length === 0) {
return false;
}
const digit = Number.parseInt(e.key, 10);
if (Number.isNaN(digit) || digit < 1 || digit > Math.min(options.length, 9)) {
return false;
}
e.preventDefault();
if (multiSelect) {
toggleChecked(digit - 1);
} else {
submitOption(digit - 1);
}
return true;
},
[active, locked, options, multiSelect, toggleChecked, submitOption],
);
return {
active,
batchMode,
liveAsk,
options,
collapsed,
collapse,
expand,
popoverVisible,
composerAnswers,
composerLocked,
multiSelect,
locked,
selected,
setSelected,
checked,
toggleChecked,
answerText,
setAnswerText,
canSubmit,
submit,
submitOption,
submitText,
submitAnswer,
skip,
handleComposerKeyDown,
handlePopoverKeyDown,
/** The last submission failed but the question is still answerable — the
* popover surfaces this so a composer/popover answer doesn't fail
* silently (the chat card's error line is hidden while the popover is up). */
errored: status === 'error',
/** Model-supplied "Other"-style label, folded into the inline input. */
otherLabel,
draftId,
};
}