From f308b14dae1e7d4bc6373a2b2c32dc9ff8860df4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 12:17:25 -0500 Subject: [PATCH] fix(ui): isolate pending question drafts by request identity Remount question state before persistence when request identity changes, retaining same-request edits. Preserve exact canonical text at the compact-card API boundary while keeping legacy and custom-answer trimming. Co-Authored-By: Paperclip --- .../task-chat/QuestionForm.test.tsx | 28 ++++++++++++++++ ui/src/components/task-chat/QuestionForm.tsx | 8 ++++- .../TaskChatCompactInteractionCard.tsx | 2 +- .../TaskChatInteractionCard.test.tsx | 32 +++++++++++++++++++ 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/ui/src/components/task-chat/QuestionForm.test.tsx b/ui/src/components/task-chat/QuestionForm.test.tsx index b16639476e..6cdcb5e24a 100644 --- a/ui/src/components/task-chat/QuestionForm.test.tsx +++ b/ui/src/components/task-chat/QuestionForm.test.tsx @@ -52,6 +52,34 @@ describe("QuestionForm initial text", () => { expect(Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Submit answers")!.disabled).toBe(true); expect(submit).not.toHaveBeenCalled(); }); + it("isolates pending requests with the same field ids and restores each edited draft when switching back", async () => { + async function showRequest(id: string, initialText: string) { + const set = { ...questionSet, questions: [{ ...questionSet.questions[0]!, initialText }] }; + await act(async () => root.render( + , + )); + } + const storedText = (id: string) => JSON.parse(localStorage.getItem(`question:${id}`)!).answers.draft.text; + + await showRequest("request-a", "Provider draft A"); + await edit("Operator edit A"); + await showRequest("request-b", "Provider draft B"); + expect(input().value).toBe("Provider draft B"); + expect(storedText("request-a")).toBe("Operator edit A"); + expect(storedText("request-b")).toBe("Provider draft B"); + await edit("Operator edit B"); + + await showRequest("request-a", "Refreshed provider draft A"); + expect(input().value).toBe("Operator edit A"); + await showRequest("request-b", "Refreshed provider draft B"); + expect(input().value).toBe("Operator edit B"); + expect(storedText("request-a")).toBe("Operator edit A"); + expect(storedText("request-b")).toBe("Operator edit B"); + expect(submit).not.toHaveBeenCalled(); + await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Submit answers")!.click()); + expect(submit).toHaveBeenCalledExactlyOnceWith({ schema: "paperclip.question_response.v1", answers: { draft: { text: "Operator edit B" } } }); + }); + it("prefers an existing response and keeps text constraints on defaults", async () => { await render(questionSet, { schema: "paperclip.question_response.v1", answers: { draft: { text: "Existing answer" } } }); expect(input().value).toBe("Existing answer"); diff --git a/ui/src/components/task-chat/QuestionForm.tsx b/ui/src/components/task-chat/QuestionForm.tsx index 824c6fad26..3e2ce71b8d 100644 --- a/ui/src/components/task-chat/QuestionForm.tsx +++ b/ui/src/components/task-chat/QuestionForm.tsx @@ -230,7 +230,13 @@ export function QuestionResponseSummary({ ); } -export function QuestionForm({ +export function QuestionForm(props: QuestionFormProps) { + // Takeovers can reuse this component for another pending request. Remount + // before draft persistence runs so one request cannot overwrite another. + return ; +} + +function QuestionFormForRequest({ id, questionSet, initialResponse, diff --git a/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx b/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx index c655bb299e..1a04ba2fff 100644 --- a/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx +++ b/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx @@ -757,7 +757,7 @@ function AskUserQuestionsCard({ const answer = response.answers[question.id]; const otherText = question.answerMode === "text" - ? answer?.text?.trim() + ? (interaction.payload.questionSet ? answer?.text : answer?.text?.trim()) : answer?.customText?.trim(); return { questionId: question.id, diff --git a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx index f720a5ae69..c912e2e727 100644 --- a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx +++ b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx @@ -683,6 +683,38 @@ describe("TaskChatInteractionCard", () => { ]); }); + it.each(["canonical_text", "canonical_custom", "legacy_custom"])("preserves the %s answer mapping", async (mode) => { + const submit = vi.fn(); + const interaction = structuredClone(pendingAskUserQuestionsInteraction); + interaction.payload.questions = [{ + id: "draft", prompt: "Edit", selectionMode: "single", required: true, + options: [{ id: "preset", label: "Preset" }, { id: "other", label: "Custom answer", freeText: true }], + }]; + interaction.payload.questionSet = mode === "legacy_custom" ? undefined : { + schema: "paperclip.question_set.v1", + questions: [mode === "canonical_text" + ? { id: "draft", prompt: "Edit", required: true, answerMode: "text", initialText: "Provider draft" } + : { id: "draft", prompt: "Edit", required: true, answerMode: "single_select", options: [{ id: "preset", label: "Preset" }], customAnswer: { enabled: true, label: "Custom answer" } }], + }; + await act(async () => root.render( + + + , + )); + if (mode !== "canonical_text") { + await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Custom answer"))!.click()); + } + const edited = "\n Operator 漢字 edit\nLiteral \\n stays literal. \n"; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(container.querySelector("textarea")!, edited); + container.querySelector("textarea")!.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent?.trim() === "Send answers" || button.textContent?.trim() === "Submit answers")!.click()); + expect(submit).toHaveBeenCalledExactlyOnceWith(interaction, [{ + questionId: "draft", optionIds: [], otherText: mode === "canonical_text" ? edited : edited.trim(), + }]); + }); + it("paginates item verdicts instead of expanding the whole review set", async () => { flushSync(() => { root.render(