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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-29 12:17:25 -05:00
1 parent c5a4714add
commit f308b14dae
4 files changed
+68 -2

No files matched your search

@@ -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(
<QuestionForm id={id} questionSet={set} draftKey={`question:${id}`} onSubmit={submit} />,
));
}
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");
+7 -1
View File
@@ -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 <QuestionFormForRequest key={JSON.stringify([props.id, props.draftKey])} {...props} />;
}
function QuestionFormForRequest({
id,
questionSet,
initialResponse,
@@ -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,
@@ -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(
<TooltipProvider><ThemeProvider>
<TaskChatInteractionCard item={interactionItem(interaction)} presentation="takeover" onSubmitInteractionAnswers={submit} />
</ThemeProvider></TooltipProvider>,
));
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(