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:
Marco Beretta 2026-09-06 19:34:48 +02:00
parent 9441da45c3
commit 27c457da10
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
3 changed files with 78 additions and 19 deletions

View file

@ -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';

View file

@ -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: {

View file

@ -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();
}