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(