diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 79d393fc8d1f..667eb0e11f6c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -148,6 +148,7 @@ import { } from "./chat/timelineScrollAnchoring"; import { buildPendingUserInputAnswers, + carryDisplacedCustomAnswerIntoPrompt, derivePendingUserInputProgress, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, @@ -8810,6 +8811,16 @@ export default function ChatView(props: ChatViewProps) { if (!activePendingUserInput) { return; } + // The option replaces the custom answer. Anything typed there is the + // user's text, so it goes back to the thread draft instead of vanishing. + const displacedAnswer = + pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[questionId]?.customAnswer; + const currentPrompt = + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? ""; + const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer); + if (nextPrompt !== currentPrompt) { + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + } setPendingUserInputAnswersByRequestId((existing) => { const question = (activePendingProgress?.activeQuestion?.id === questionId @@ -8839,7 +8850,10 @@ export default function ChatView(props: ChatViewProps) { activePendingProgress?.activeQuestion, activePendingUserInput, activePendingRequestKey, + composerDraftTarget, composerRef, + pendingUserInputAnswersByRequestId, + setComposerDraftPrompt, ], ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8f91f08c7093..c4a54232bbb0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3080,6 +3080,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useEffect(() => { const nextCustomAnswer = activePendingProgress?.customAnswer; if (typeof nextCustomAnswer !== "string") { + // The question is gone and the editor shows the thread draft again. The + // ref still holds the last answer text, and Send reads the ref. + if (lastSyncedPendingInputRef.current !== null) { + promptRef.current = prompt; + } lastSyncedPendingInputRef.current = null; return; } @@ -3114,6 +3119,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingProgress?.customAnswer, activePendingProgress?.activeQuestion?.id, activePendingUserInput?.requestId, + prompt, promptRef, ]); diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index f2f093a5c826..350087c3aaff 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildPendingUserInputAnswers, + carryDisplacedCustomAnswerIntoPrompt, countAnsweredPendingUserInputQuestions, derivePendingUserInputProgress, resolvePendingUserInputAnswer, @@ -321,3 +322,22 @@ it("accepts attachment-only answers after every upload finishes", () => { }), ).toBeNull(); }); + +describe("carryDisplacedCustomAnswerIntoPrompt", () => { + it("keeps the thread draft when nothing was typed into the answer", () => { + expect(carryDisplacedCustomAnswerIntoPrompt("draft", undefined)).toBe("draft"); + expect(carryDisplacedCustomAnswerIntoPrompt("draft", " ")).toBe("draft"); + }); + + it("moves the typed answer into an empty thread draft", () => { + expect(carryDisplacedCustomAnswerIntoPrompt("", "also rename the flag ")).toBe( + "also rename the flag", + ); + }); + + it("appends the typed answer after an existing thread draft", () => { + expect(carryDisplacedCustomAnswerIntoPrompt("first half\n", "second half")).toBe( + "first half\n\nsecond half", + ); + }); +}); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 5ac004649a27..5d22e56105aa 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -82,6 +82,28 @@ export function setPendingUserInputCustomAnswer( }; } +const DISPLACED_ANSWER_SEPARATOR = "\n\n"; + +/** + * Selecting an option replaces the custom answer, because a non-empty custom + * answer outranks selected options in `resolvePendingUserInputAnswer`. Text the + * user typed into the answer field must not vanish on that click: it moves back + * into the thread draft, after whatever was already waiting there. + */ +export function carryDisplacedCustomAnswerIntoPrompt( + prompt: string, + customAnswer: string | undefined, +): string { + const displaced = customAnswer?.trim() ?? ""; + if (displaced.length === 0) { + return prompt; + } + if (prompt.trim().length === 0) { + return displaced; + } + return `${prompt.trimEnd()}${DISPLACED_ANSWER_SEPARATOR}${displaced}`; +} + export function togglePendingUserInputOptionSelection( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined,