mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-23 18:43:21 +00:00
fix(client): recover a released composer stash on revisit
Recovery of the ordinary unsent message stashed while the card owns the composer was gated on `lastAnswerableRef` — what this hook happened to observe. Navigating away mid-resume clears that ref while the stash rightly stays put, so a resume that succeeded or expired while the user was elsewhere left nobody to hand the message back: returning to the conversation found `answerable === false` with no ref, exited early, and the text sat unreachable in `releasedComposerTextAtom` for the session. The stash carries its conversation now, and recovery keys on that, so a revisit is a hand-back like any other exit. Only the exit this hook armed knows what the composer held when the resume started; on a revisit an empty composer is the only target that cannot overwrite something newer, and either way the entry is dropped.
This commit is contained in:
parent
9441da45c3
commit
27c457da10
3 changed files with 78 additions and 19 deletions
|
|
@ -17,8 +17,15 @@ export const askAnswerCheckedAtom = atom<number[]>([]);
|
|||
/** Free-form answers keyed by pending action id. */
|
||||
export const askAnswerTextAtom = atom<Record<string, string>>({});
|
||||
|
||||
/** Ordinary composer text keyed by pending action id while the card owns it. */
|
||||
export const releasedComposerTextAtom = atom<Record<string, string>>({});
|
||||
/**
|
||||
* Ordinary composer text stashed while the card owns the composer, keyed by
|
||||
* pending action id. The conversation rides along because every hand-back is
|
||||
* scoped to it: a resume that settles after the user navigated away leaves no
|
||||
* one watching the exit, so returning to that conversation is the only
|
||||
* remaining chance to give the message back.
|
||||
*/
|
||||
export type ReleasedComposerStash = { conversationId: string | null; text: string };
|
||||
export const releasedComposerTextAtom = atom<Record<string, ReleasedComposerStash>>({});
|
||||
|
||||
/** Submission lifecycle shared by composer and message-content surfaces. */
|
||||
export type AskAnswerStatus = 'idle' | 'submitting' | 'submitted' | 'expired' | 'error';
|
||||
|
|
|
|||
|
|
@ -268,7 +268,9 @@ describe('useAskAnswerMode', () => {
|
|||
);
|
||||
|
||||
it('preserves newer composer edits when an in-flight question expires', () => {
|
||||
jotaiStore.set(releasedComposerTextAtom, { a1: 'an older message' });
|
||||
jotaiStore.set(releasedComposerTextAtom, {
|
||||
a1: { conversationId: 'conversation-1', text: 'an older message' },
|
||||
});
|
||||
mockUseGetMessages.mockReturnValue({ data: liveAsk });
|
||||
const { rerender } = renderHook(() => useAskAnswerMode('conversation-1'), {
|
||||
wrapper: JotaiWrapper,
|
||||
|
|
@ -284,7 +286,9 @@ describe('useAskAnswerMode', () => {
|
|||
});
|
||||
|
||||
it('does not restore a departed conversation stash into a different composer', () => {
|
||||
jotaiStore.set(releasedComposerTextAtom, { a1: 'message for conversation A' });
|
||||
jotaiStore.set(releasedComposerTextAtom, {
|
||||
a1: { conversationId: 'conversation-1', text: 'message for conversation A' },
|
||||
});
|
||||
mockUseGetMessages.mockReturnValue({ data: liveAsk });
|
||||
const { rerender } = renderHook(({ id }) => useAskAnswerMode(id), {
|
||||
initialProps: { id: 'conversation-1' },
|
||||
|
|
@ -295,16 +299,46 @@ describe('useAskAnswerMode', () => {
|
|||
|
||||
expect(mockSetComposerText).not.toHaveBeenCalled();
|
||||
expect(jotaiStore.get(releasedComposerTextAtom)).toEqual({
|
||||
a1: 'message for conversation A',
|
||||
a1: { conversationId: 'conversation-1', text: 'message for conversation A' },
|
||||
});
|
||||
});
|
||||
|
||||
it('hands a stash back when its conversation is revisited after the answer settled', () => {
|
||||
/** Navigating away mid-resume leaves nobody watching the exit, so the
|
||||
* settle drops the question without restoring anything. The stash has to
|
||||
* survive until its own conversation comes back, or the ordinary unsent
|
||||
* message is unreachable for the rest of the session. */
|
||||
jotaiStore.set(releasedComposerTextAtom, {
|
||||
a1: { conversationId: 'conversation-1', text: 'message for conversation A' },
|
||||
});
|
||||
mockUseGetMessages.mockReturnValue({ data: liveAsk });
|
||||
mockAskStatus = 'submitting';
|
||||
const { rerender } = renderHook(({ id }) => useAskAnswerMode(id), {
|
||||
initialProps: { id: 'conversation-1' },
|
||||
wrapper: JotaiWrapper,
|
||||
});
|
||||
mockUseGetMessages.mockReturnValue({ data: null });
|
||||
rerender({ id: 'conversation-2' });
|
||||
expect(mockSetComposerText).not.toHaveBeenCalled();
|
||||
|
||||
/** Drafts are off, so the revisited composer opens empty — the only state
|
||||
* in which handing the stash back cannot overwrite something newer. */
|
||||
mockGetComposerText.mockReturnValue('');
|
||||
mockAskStatus = 'submitted';
|
||||
rerender({ id: 'conversation-1' });
|
||||
|
||||
expect(mockSetComposerText).toHaveBeenLastCalledWith('text', 'message for conversation A');
|
||||
expect(jotaiStore.get(releasedComposerTextAtom)).toEqual({});
|
||||
});
|
||||
|
||||
it('hands the released composer text back when the answer is submitted', () => {
|
||||
/** Stashed by a prior expand: the question was collapsed, an ordinary
|
||||
* message was typed in the released composer, then the question was moved
|
||||
* back. Submitting from there never goes through `collapse`, so the
|
||||
* message has to be restored here or it dies with the question. */
|
||||
jotaiStore.set(releasedComposerTextAtom, { a1: 'an ordinary unsent message' });
|
||||
jotaiStore.set(releasedComposerTextAtom, {
|
||||
a1: { conversationId: 'conversation-1', text: 'an ordinary unsent message' },
|
||||
});
|
||||
mockUseGetMessages.mockReturnValue({ data: liveAsk });
|
||||
mockSubmitAskAnswer.mockImplementation(
|
||||
(_actionId: string, _answer: string, options?: { onSuccess?: () => void }) => {
|
||||
|
|
@ -328,7 +362,9 @@ describe('useAskAnswerMode', () => {
|
|||
/** Only the free-text path sets `consumedComposerText`, so gating the
|
||||
* restore on it discarded the stash whenever the user answered by
|
||||
* clicking an option (or skipping) instead of typing. */
|
||||
jotaiStore.set(releasedComposerTextAtom, { a1: 'an ordinary unsent message' });
|
||||
jotaiStore.set(releasedComposerTextAtom, {
|
||||
a1: { conversationId: 'conversation-1', text: 'an ordinary unsent message' },
|
||||
});
|
||||
const askWithOptions = {
|
||||
actionId: 'a1',
|
||||
question: {
|
||||
|
|
|
|||
|
|
@ -162,22 +162,37 @@ export default function useAskAnswerMode(conversationId?: string | null) {
|
|||
return;
|
||||
}
|
||||
lastAnswerableRef.current = null;
|
||||
if (!previous || previous.conversationId !== conversationId) {
|
||||
/** Recovery is keyed by the stash's own conversation, not by what this
|
||||
* hook happened to observe. Navigating away mid-resume clears the ref
|
||||
* while the stash rightly stays put, and the settle then lands with
|
||||
* nobody watching that exit — so coming back is the last chance to hand
|
||||
* the message over, and gating on the ref stranded it in the atom. */
|
||||
const stashed = Object.entries(releasedComposerText).find(
|
||||
([, stash]) => stash.conversationId === conversationId,
|
||||
);
|
||||
if (!stashed) {
|
||||
return;
|
||||
}
|
||||
const released = releasedComposerText[previous.actionId];
|
||||
if (released == null) {
|
||||
return;
|
||||
}
|
||||
if (!previous.submitting || formContext?.getValues('text') === previous.composerText) {
|
||||
formContext?.setValue('text', released);
|
||||
const [actionId, stash] = stashed;
|
||||
const composerText = formContext?.getValues('text') ?? '';
|
||||
/** Only the exit this hook armed knows what the composer held when the
|
||||
* resume started. On a revisit the composer is whatever the route
|
||||
* restored, so an empty one is the only target that cannot clobber
|
||||
* something newer. Either way the entry goes: anything the user typed
|
||||
* since is newer than the stash. */
|
||||
const restorable =
|
||||
previous != null && previous.actionId === actionId
|
||||
? !previous.submitting || composerText === previous.composerText
|
||||
: composerText === '';
|
||||
if (restorable) {
|
||||
formContext?.setValue('text', stash.text);
|
||||
}
|
||||
setReleasedComposerText((current) => {
|
||||
if (current[previous.actionId] == null) {
|
||||
if (current[actionId] == null) {
|
||||
return current;
|
||||
}
|
||||
const next = { ...current };
|
||||
delete next[previous.actionId];
|
||||
delete next[actionId];
|
||||
return next;
|
||||
});
|
||||
}, [
|
||||
|
|
@ -246,7 +261,7 @@ export default function useAskAnswerMode(conversationId?: string | null) {
|
|||
* saving between expanding and collapsing. */
|
||||
const released = releasedComposerText[liveAsk.actionId];
|
||||
if (released) {
|
||||
formContext?.setValue('text', released);
|
||||
formContext?.setValue('text', released.text);
|
||||
setReleasedComposerText((current) => {
|
||||
if (current[liveAsk.actionId] == null) {
|
||||
return current;
|
||||
|
|
@ -290,7 +305,7 @@ export default function useAskAnswerMode(conversationId?: string | null) {
|
|||
if (released) {
|
||||
setReleasedComposerText((current) => ({
|
||||
...current,
|
||||
[liveAsk.actionId]: released,
|
||||
[liveAsk.actionId]: { conversationId: conversationId ?? null, text: released },
|
||||
}));
|
||||
}
|
||||
formContext?.setValue('text', answerText);
|
||||
|
|
@ -308,6 +323,7 @@ export default function useAskAnswerMode(conversationId?: string | null) {
|
|||
}
|
||||
}, [
|
||||
liveAsk,
|
||||
conversationId,
|
||||
batchMode,
|
||||
saveDrafts,
|
||||
formContext,
|
||||
|
|
@ -394,7 +410,7 @@ export default function useAskAnswerMode(conversationId?: string | null) {
|
|||
const released = releasedComposerTextRef.current[submittedActionId];
|
||||
if (currentScope.formContext?.getValues('text') === submittedComposerText) {
|
||||
if (released) {
|
||||
currentScope.formContext.setValue('text', released);
|
||||
currentScope.formContext.setValue('text', released.text);
|
||||
} else if (consumedComposerText || (wasActive && saveDrafts)) {
|
||||
currentScope.formContext.reset();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue