authorgravatar for maxtheyoung@gmail.comMaxwell Young <maxtheyoung@gmail.com> 2026-10-11 00:32:05+13:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 06:32:05-05:00
log91474e56f66888c5ab3856a782a72613e758c401
tree4c7a60195c849df576cb50d3718435d92b8dae27
parentadbecdac7b6f1a167cc389b3d5fe5be8ec70afdf
signature Signed by PGP key B5690EEEBB952194

fix(web): cancel question auto-advance after navigation (#17364)


2 files changed, 150 insertions(+), 10 deletions(-)

apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.test.tsx created+144
......@@ -0,0 +1,144 @@
1import { RuntimeRequestId } from "@t3tools/contracts";
2import { act, useState, type ReactNode } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";
4import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
5
6import {
7 togglePendingUserInputOptionSelection,
8 type PendingUserInputDraftAnswer,
9} from "../../pendingUserInput";
10
11vi.mock("../ui/collapsible", () => {
12 const Children = ({ children }: { children: ReactNode }) => <>{children}</>;
13 return {
14 Collapsible: Children,
15 CollapsiblePanel: Children,
16 CollapsibleTrigger: Children,
17 };
18});
19
20import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
21
22const prompt = {
23 requestId: RuntimeRequestId.make("question-navigation"),
24 createdAt: "2026-10-09T00:00:00.000Z",
25 dismissible: false,
26 responseCapability: "live" as const,
27 questions: [0, 1, 2].map((index) => ({
28 id: `question-${index}`,
29 header: `Question ${index + 1}`,
30 question: `Choose for question ${index + 1}`,
31 options: [{ label: "Incremental", description: "One module at a time" }],
32 multiSelect: false,
33 })),
34};
35
36function Questions({ initialIndex = 0 }: { initialIndex?: number }) {
37 const [questionIndex, setQuestionIndex] = useState(initialIndex);
38 const [answers, setAnswers] = useState<Record<string, PendingUserInputDraftAnswer>>({});
39 const advance = () => setQuestionIndex(questionIndex + 1);
40 return (
41 <>
42 <output>{questionIndex}</output>
43 <button onClick={advance}>Next</button>
44 <button onClick={() => setQuestionIndex(questionIndex - 1)}>Previous</button>
45 <ComposerPendingUserInputPanel
46 pendingUserInputs={[prompt]}
47 disabled={false}
48 respondingRequestIds={[]}
49 answers={answers}
50 questionIndex={questionIndex}
51 onAdvance={advance}
52 onToggleOption={(questionId, value) => {
53 const question = prompt.questions.find((entry) => entry.id === questionId)!;
54 setAnswers((existing) => ({
55 ...existing,
56 [questionId]: togglePendingUserInputOptionSelection(
57 question,
58 existing[questionId],
59 value,
60 ),
61 }));
62 }}
63 onDismiss={() => {}}
64 />
65 </>
66 );
67}
68
69let renderer: ReactTestRenderer | undefined;
70beforeEach(() => {
71 vi.useFakeTimers();
72 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
73 vi.stubGlobal("window", {
74 setTimeout: globalThis.setTimeout,
75 clearTimeout: globalThis.clearTimeout,
76 });
77 vi.stubGlobal("document", { addEventListener: vi.fn(), removeEventListener: vi.fn() });
78});
79afterEach(async () => {
80 await act(async () => renderer?.unmount());
81 renderer = undefined;
82 vi.unstubAllGlobals();
83 vi.useRealTimers();
84});
85
86async function chooseOption() {
87 await act(async () => {
88 renderer!.root
89 .findAllByType("button")
90 .find((button) =>
91 button.findAllByType("span").some((span) => span.children.includes("Incremental")),
92 )!
93 .props.onClick();
94 });
95}
96
97async function navigate(label: string) {
98 await act(async () => {
99 renderer!.root
100 .findAllByType("button")
101 .find((button) => button.children.includes(label))!
102 .props.onClick();
103 });
104}
105
106function activeIndex() {
107 return renderer!.root.findByType("output").children.join("");
108}
109
110it("does not skip the next unanswered question after manual advance", async () => {
111 await act(async () => {
112 renderer = create(<Questions />);
113 });
114 await chooseOption();
115 await navigate("Next");
116 expect(activeIndex()).toBe("1");
117 await act(async () => vi.advanceTimersByTime(200));
118 expect(activeIndex()).toBe("1");
119
120 await chooseOption();
121 await act(async () => vi.advanceTimersByTime(200));
122 expect(activeIndex()).toBe("2");
123});
124
125it("does not undo previous-question navigation with a stale timer", async () => {
126 await act(async () => {
127 renderer = create(<Questions initialIndex={1} />);
128 });
129 await chooseOption();
130 await navigate("Previous");
131 await act(async () => vi.advanceTimersByTime(200));
132 expect(activeIndex()).toBe("0");
133});
134
135it("still auto-advances once after the answer draft rerenders the same question", async () => {
136 await act(async () => {
137 renderer = create(<Questions />);
138 });
139 await chooseOption();
140 await act(async () => vi.advanceTimersByTime(200));
141 expect(activeIndex()).toBe("1");
142 await act(async () => vi.advanceTimersByTime(200));
143 expect(activeIndex()).toBe("1");
144});
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+6-10
......@@ -93,13 +93,6 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
9393 onAdvanceRef.current = onAdvance;
9494 }, [onAdvance]);
9595
96 useEffect(() => {
97 if (disabled && autoAdvanceTimerRef.current !== null) {
98 window.clearTimeout(autoAdvanceTimerRef.current);
99 autoAdvanceTimerRef.current = null;
100 }
101 }, [disabled]);
102
10396 useEffect(() => {
10497 if (!activeQuestion || activeQuestion.multiSelect || !optimisticSingleSelect) {
10598 return;
......@@ -121,14 +114,17 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
121114 progress.selectedOptionValues,
122115 ]);
123116
124 // Clear auto-advance timer on unmount
117 // A delayed answer only advances its own question, while permission remains granted.
125118 useEffect(() => {
126 return () => {
119 const clearAutoAdvance = () => {
127120 if (autoAdvanceTimerRef.current !== null) {
128121 window.clearTimeout(autoAdvanceTimerRef.current);
122 autoAdvanceTimerRef.current = null;
129123 }
130124 };
131 }, []);
125 if (disabled || activeQuestion?.id === undefined) clearAutoAdvance();
126 return clearAutoAdvance;
127 }, [activeQuestion?.id, disabled]);
132128
133129 const handleOptionSelection = useCallback(
134130 (questionId: string, optionValue: string) => {