diff --git a/tests/runner-e2e/chat-catalog-domains/identity-questions-and-approvals.ts b/tests/runner-e2e/chat-catalog-domains/identity-questions-and-approvals.ts
index e47f1205311..17c2b9673b0 100644
--- a/tests/runner-e2e/chat-catalog-domains/identity-questions-and-approvals.ts
+++ b/tests/runner-e2e/chat-catalog-domains/identity-questions-and-approvals.ts
@@ -248,7 +248,7 @@ export const questionIdentityCases = [
if (!state.validationObserved) throw new Error("Required multipart question advanced or changed before an answer");
await form.getByRole("radio", { name: "Researchers", exact: true }).click();
await form.getByRole("button", { name: "Next", exact: true }).click();
- await form.getByRole("button", { name: "Back", exact: true }).click();
+ await form.getByRole("button", { name: "Previous question", exact: true }).click();
state.retainedAudience = await form.getByRole("radio", { name: "Researchers", exact: true }).isChecked();
await form.getByRole("button", { name: "Next", exact: true }).click();
await form.getByRole("radio", { name: "Clear", exact: true }).click();
diff --git a/ui/src/components/task-chat/QuestionForm.catalog-navigation.test.tsx b/ui/src/components/task-chat/QuestionForm.catalog-navigation.test.tsx
new file mode 100644
index 00000000000..39c8a1eb832
--- /dev/null
+++ b/ui/src/components/task-chat/QuestionForm.catalog-navigation.test.tsx
@@ -0,0 +1,53 @@
+// @vitest-environment jsdom
+import { readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import type { PaperclipQuestionSet } from "@paperclipai/adapter-utils";
+import { QuestionForm } from "./QuestionForm";
+vi.mock("./TaskChatRichInput", () => ({ TaskChatRichInput: () => }));
+vi.mock("@/components/MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: string }) =>
{children}
}));
+function queryByRole(scope: HTMLElement, role: "button" | "radio", options: { name: string; exact: true }): HTMLElement | null {
+ return [...scope.querySelectorAll("button")].find(element =>
+ (element.getAttribute("role") ?? "button") === role
+ && (element.getAttribute("aria-label") ?? element.textContent?.trim()) === options.name) ?? null;
+}
+function getByRole(scope: HTMLElement, role: "button" | "radio", options: { name: string; exact: true }): HTMLElement {
+ const found = queryByRole(scope, role, options);
+ if (!found) throw new Error(`Missing rendered ${role} ${options.name}`);
+ return found;
+}
+const fixture = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../../../../tests/runner-e2e/chat-catalog-domains/identity-questions-and-approvals.ts"), "utf8");
+const previousSelector = fixture.match(/await form\.getByRole\("button", \{ name: "([^"]+)", exact: true \}\)\.click\(\);\s*state\.retainedAudience/);
+const set: PaperclipQuestionSet = { schema: "paperclip.question_set.v1", questions: [
+ { id: "audience", prompt: "Who is the audience?", required: true, answerMode: "single_select", options: [{ id: "researchers", label: "Researchers" }] },
+ { id: "tone", prompt: "What tone should we use?", required: true, answerMode: "single_select", options: [{ id: "clear", label: "Clear" }] },
+] };
+let host: HTMLDivElement, form: HTMLDivElement, root: Root;
+const submit = vi.fn();
+beforeEach(() => { localStorage.clear(); submit.mockReset(); host = document.createElement("div"); document.body.append(host); form = document.createElement("div"); form.dataset.testid = "task-chat-composer-takeover"; host.append(form); root = createRoot(form); });
+afterEach(async () => { await act(async () => root.unmount()); host.remove(); localStorage.clear(); });
+async function secondPage() {
+ await act(async () => root.render());
+ await act(async () => getByRole(form, "button", { name: "Next", exact: true }).click());
+ expect(form.textContent).toContain("This question is required."); expect(form.textContent).not.toContain("What tone should we use?"); expect(submit).not.toHaveBeenCalled();
+ await act(async () => getByRole(form, "radio", { name: "Researchers", exact: true }).click());
+ await act(async () => getByRole(form, "button", { name: "Next", exact: true }).click());
+ expect(form.textContent).toContain("What tone should we use?");
+}
+it("uses the actual catalog consumer selector on rendered production pagination and retains the first answer", async () => {
+ await secondPage(); expect(previousSelector).not.toBeNull();
+ const previous = getByRole(form, "button", { name: previousSelector![1]!, exact: true });
+ expect((previous as HTMLButtonElement).disabled).toBe(false); await act(async () => previous.click());
+ expect(getByRole(form, "radio", { name: "Researchers", exact: true }).getAttribute("aria-checked")).toBe("true");
+ expect(form.textContent).not.toContain("What tone should we use?"); expect(submit).not.toHaveBeenCalled();
+});
+it("does not substitute wrong navigation names or another question's previous control", async () => {
+ await secondPage();
+ for (const name of ["Back", "Previous item"]) expect(queryByRole(form, "button", { name, exact: true })).toBeNull();
+ expect(getByRole(form, "button", { name: "Previous question", exact: true })).not.toBe(getByRole(form, "button", { name: "Next question", exact: true }));
+ const foreign = document.createElement("button"); foreign.setAttribute("aria-label", "Previous question"); host.append(foreign);
+ expect(getByRole(form, "button", { name: "Previous question", exact: true })).not.toBe(foreign); expect(submit).not.toHaveBeenCalled();
+});