diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c414934fa0da6a486562b697632ba562ec4904eb --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.test.tsx @@ -0,0 +1,144 @@ +import { RuntimeRequestId } from "@t3tools/contracts"; +import { act, useState, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { + togglePendingUserInputOptionSelection, + type PendingUserInputDraftAnswer, +} from "../../pendingUserInput"; + +vi.mock("../ui/collapsible", () => { + const Children = ({ children }: { children: ReactNode }) => <>{children}; + return { + Collapsible: Children, + CollapsiblePanel: Children, + CollapsibleTrigger: Children, + }; +}); + +import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; + +const prompt = { + requestId: RuntimeRequestId.make("question-navigation"), + createdAt: "2026-10-09T00:00:00.000Z", + dismissible: false, + responseCapability: "live" as const, + questions: [0, 1, 2].map((index) => ({ + id: `question-${index}`, + header: `Question ${index + 1}`, + question: `Choose for question ${index + 1}`, + options: [{ label: "Incremental", description: "One module at a time" }], + multiSelect: false, + })), +}; + +function Questions({ initialIndex = 0 }: { initialIndex?: number }) { + const [questionIndex, setQuestionIndex] = useState(initialIndex); + const [answers, setAnswers] = useState>({}); + const advance = () => setQuestionIndex(questionIndex + 1); + return ( + <> + {questionIndex} + + + { + const question = prompt.questions.find((entry) => entry.id === questionId)!; + setAnswers((existing) => ({ + ...existing, + [questionId]: togglePendingUserInputOptionSelection( + question, + existing[questionId], + value, + ), + })); + }} + onDismiss={() => {}} + /> + + ); +} + +let renderer: ReactTestRenderer | undefined; +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + }); + vi.stubGlobal("document", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); +}); +afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +async function chooseOption() { + await act(async () => { + renderer!.root + .findAllByType("button") + .find((button) => + button.findAllByType("span").some((span) => span.children.includes("Incremental")), + )! + .props.onClick(); + }); +} + +async function navigate(label: string) { + await act(async () => { + renderer!.root + .findAllByType("button") + .find((button) => button.children.includes(label))! + .props.onClick(); + }); +} + +function activeIndex() { + return renderer!.root.findByType("output").children.join(""); +} + +it("does not skip the next unanswered question after manual advance", async () => { + await act(async () => { + renderer = create(); + }); + await chooseOption(); + await navigate("Next"); + expect(activeIndex()).toBe("1"); + await act(async () => vi.advanceTimersByTime(200)); + expect(activeIndex()).toBe("1"); + + await chooseOption(); + await act(async () => vi.advanceTimersByTime(200)); + expect(activeIndex()).toBe("2"); +}); + +it("does not undo previous-question navigation with a stale timer", async () => { + await act(async () => { + renderer = create(); + }); + await chooseOption(); + await navigate("Previous"); + await act(async () => vi.advanceTimersByTime(200)); + expect(activeIndex()).toBe("0"); +}); + +it("still auto-advances once after the answer draft rerenders the same question", async () => { + await act(async () => { + renderer = create(); + }); + await chooseOption(); + await act(async () => vi.advanceTimersByTime(200)); + expect(activeIndex()).toBe("1"); + await act(async () => vi.advanceTimersByTime(200)); + expect(activeIndex()).toBe("1"); +}); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 687c92514178b6cdb1b5c2da77e89667e50bbc23..1e8caefac2d405dbc72ab784935a33479bf256d9 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -93,13 +93,6 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( onAdvanceRef.current = onAdvance; }, [onAdvance]); - useEffect(() => { - if (disabled && autoAdvanceTimerRef.current !== null) { - window.clearTimeout(autoAdvanceTimerRef.current); - autoAdvanceTimerRef.current = null; - } - }, [disabled]); - useEffect(() => { if (!activeQuestion || activeQuestion.multiSelect || !optimisticSingleSelect) { return; @@ -121,14 +114,17 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( progress.selectedOptionValues, ]); - // Clear auto-advance timer on unmount + // A delayed answer only advances its own question, while permission remains granted. useEffect(() => { - return () => { + const clearAutoAdvance = () => { if (autoAdvanceTimerRef.current !== null) { window.clearTimeout(autoAdvanceTimerRef.current); + autoAdvanceTimerRef.current = null; } }; - }, []); + if (disabled || activeQuestion?.id === undefined) clearAutoAdvance(); + return clearAutoAdvance; + }, [activeQuestion?.id, disabled]); const handleOptionSelection = useCallback( (questionId: string, optionValue: string) => {