authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-06 16:08:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-06 16:08:46-07:00
log11d456f6c8404be6617b7299a9db9ad5538ee061
tree9fe12742965bacbc4e97752035207b7f094fbbe2
parenta221542921fd01271f67c6bfa2a0fe7f7ff78dc3
signature Signed by PGP key B5690EEEBB952194

Support multi-select pending user inputs (#1797)


9 files changed, 398 insertions(+), 129 deletions(-)

apps/server/src/provider/Layers/CodexAdapter.test.ts+2
...@@ -723,6 +723,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {...@@ -723,6 +723,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
723 description: "Allow workspace writes only",723 description: "Allow workspace writes only",
724 },724 },
725 ],725 ],
726 multiSelect: true,
726 },727 },
727 ],728 ],
728 },729 },
...@@ -749,6 +750,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {...@@ -749,6 +750,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
749 if (events[0]?.type === "user-input.requested") {750 if (events[0]?.type === "user-input.requested") {
750 assert.equal(events[0].requestId, "req-user-input-1");751 assert.equal(events[0].requestId, "req-user-input-1");
751 assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode");752 assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode");
753 assert.equal(events[0].payload.questions[0]?.multiSelect, true);
752 }754 }
753755
754 assert.equal(events[1]?.type, "user-input.resolved");756 assert.equal(events[1]?.type, "user-input.resolved");
apps/server/src/provider/Layers/CodexAdapter.ts+2
...@@ -382,6 +382,7 @@ function toUserInputQuestions(payload: Record<string, unknown> | undefined) {...@@ -382,6 +382,7 @@ function toUserInputQuestions(payload: Record<string, unknown> | undefined) {
382 header,382 header,
383 question: prompt,383 question: prompt,
384 options,384 options,
385 multiSelect: question.multiSelect === true,
385 };386 };
386 })387 })
387 .filter(388 .filter(
...@@ -392,6 +393,7 @@ function toUserInputQuestions(payload: Record<string, unknown> | undefined) {...@@ -392,6 +393,7 @@ function toUserInputQuestions(payload: Record<string, unknown> | undefined) {
392 header: string;393 header: string;
393 question: string;394 question: string;
394 options: Array<{ label: string; description: string }>;395 options: Array<{ label: string; description: string }>;
396 multiSelect: boolean;
395 } => question !== undefined,397 } => question !== undefined,
396 );398 );
397399
apps/web/src/components/ChatView.browser.tsx+179-33
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 type ServerLifecycleWelcomePayload,12 type ServerLifecycleWelcomePayload,
13 type ThreadId,13 type ThreadId,
14 type TurnId,14 type TurnId,
15 type UserInputQuestion,
15 WS_METHODS,16 WS_METHODS,
16 OrchestrationSessionStatus,17 OrchestrationSessionStatus,
17 DEFAULT_SERVER_SETTINGS,18 DEFAULT_SERVER_SETTINGS,
...@@ -541,12 +542,51 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {...@@ -541,12 +542,51 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
541 };542 };
542}543}
543544
544function createSnapshotWithPendingUserInput(): OrchestrationReadModel {545function createSnapshotWithPendingUserInput(options?: {
546 questions?: ReadonlyArray<UserInputQuestion>;
547}): OrchestrationReadModel {
545 const snapshot = createSnapshotForTargetUser({548 const snapshot = createSnapshotForTargetUser({
546 targetMessageId: "msg-user-pending-input-target" as MessageId,549 targetMessageId: "msg-user-pending-input-target" as MessageId,
547 targetText: "question thread",550 targetText: "question thread",
548 });551 });
549552
553 const questions =
554 options?.questions ??
555 ([
556 {
557 id: "scope",
558 header: "Scope",
559 question: "What should this change cover?",
560 options: [
561 {
562 label: "Tight",
563 description: "Touch only the footer layout logic.",
564 },
565 {
566 label: "Broad",
567 description: "Also adjust the related composer controls.",
568 },
569 ],
570 multiSelect: false,
571 },
572 {
573 id: "risk",
574 header: "Risk",
575 question: "How aggressive should the imaginary plan be?",
576 options: [
577 {
578 label: "Conservative",
579 description: "Favor reliability and low-risk changes.",
580 },
581 {
582 label: "Balanced",
583 description: "Mix quick wins with one structural improvement.",
584 },
585 ],
586 multiSelect: false,
587 },
588 ] satisfies ReadonlyArray<UserInputQuestion>);
589
550 return {590 return {
551 ...snapshot,591 ...snapshot,
552 threads: snapshot.threads.map((thread) =>592 threads: snapshot.threads.map((thread) =>
...@@ -561,38 +601,7 @@ function createSnapshotWithPendingUserInput(): OrchestrationReadModel {...@@ -561,38 +601,7 @@ function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
561 summary: "User input requested",601 summary: "User input requested",
562 payload: {602 payload: {
563 requestId: "req-browser-user-input",603 requestId: "req-browser-user-input",
564 questions: [604 questions,
565 {
566 id: "scope",
567 header: "Scope",
568 question: "What should this change cover?",
569 options: [
570 {
571 label: "Tight",
572 description: "Touch only the footer layout logic.",
573 },
574 {
575 label: "Broad",
576 description: "Also adjust the related composer controls.",
577 },
578 ],
579 },
580 {
581 id: "risk",
582 header: "Risk",
583 question: "How aggressive should the imaginary plan be?",
584 options: [
585 {
586 label: "Conservative",
587 description: "Favor reliability and low-risk changes.",
588 },
589 {
590 label: "Balanced",
591 description: "Mix quick wins with one structural improvement.",
592 },
593 ],
594 },
595 ],
596 },605 },
597 turnId: null,606 turnId: null,
598 sequence: 1,607 sequence: 1,
...@@ -2902,6 +2911,143 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2902,6 +2911,143 @@ describe("ChatView timeline estimator parity (full app)", () => {
2902 }2911 }
2903 });2912 });
29042913
2914 it("does not trigger numeric option shortcuts while the composer is focused", async () => {
2915 const mounted = await mountChatView({
2916 viewport: WIDE_FOOTER_VIEWPORT,
2917 snapshot: createSnapshotWithPendingUserInput(),
2918 });
2919
2920 try {
2921 const composerEditor = await waitForComposerEditor();
2922 composerEditor.focus();
2923
2924 const event = new KeyboardEvent("keydown", {
2925 key: "2",
2926 bubbles: true,
2927 cancelable: true,
2928 });
2929 composerEditor.dispatchEvent(event);
2930 await waitForLayout();
2931
2932 expect(event.defaultPrevented).toBe(false);
2933 expect(document.body.textContent).toContain("What should this change cover?");
2934 expect(document.body.textContent).not.toContain(
2935 "How aggressive should the imaginary plan be?",
2936 );
2937 await waitForButtonByText("Next question");
2938 } finally {
2939 await mounted.cleanup();
2940 }
2941 });
2942
2943 it("submits multi-select questionnaire answers as arrays", async () => {
2944 const mounted = await mountChatView({
2945 viewport: WIDE_FOOTER_VIEWPORT,
2946 snapshot: createSnapshotWithPendingUserInput({
2947 questions: [
2948 {
2949 id: "scope",
2950 header: "Scope",
2951 question: "Which areas should this change cover?",
2952 options: [
2953 {
2954 label: "Server",
2955 description: "Touch server orchestration.",
2956 },
2957 {
2958 label: "Web",
2959 description: "Touch the browser UI.",
2960 },
2961 ],
2962 multiSelect: true,
2963 },
2964 {
2965 id: "risk",
2966 header: "Risk",
2967 question: "How aggressive should the imaginary plan be?",
2968 options: [
2969 {
2970 label: "Balanced",
2971 description: "Mix quick wins with one structural improvement.",
2972 },
2973 ],
2974 multiSelect: false,
2975 },
2976 ],
2977 }),
2978 resolveRpc: (body) => {
2979 if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
2980 return {
2981 sequence: fixture.snapshot.snapshotSequence + 1,
2982 };
2983 }
2984 return undefined;
2985 },
2986 });
2987
2988 try {
2989 const serverOption = await waitForButtonContainingText("Server");
2990 serverOption.click();
2991 await waitForLayout();
2992
2993 expect(document.body.textContent).toContain("Which areas should this change cover?");
2994
2995 const webOption = await waitForButtonContainingText("Web");
2996 webOption.click();
2997 await waitForLayout();
2998
2999 expect(document.body.textContent).toContain("Which areas should this change cover?");
3000
3001 const nextButton = await waitForButtonByText("Next question");
3002 expect(nextButton.disabled).toBe(false);
3003 nextButton.click();
3004
3005 await vi.waitFor(
3006 () => {
3007 expect(document.body.textContent).toContain(
3008 "How aggressive should the imaginary plan be?",
3009 );
3010 },
3011 { timeout: 8_000, interval: 16 },
3012 );
3013
3014 const balancedOption = await waitForButtonContainingText("Balanced");
3015 balancedOption.click();
3016
3017 const submitButton = await waitForButtonByText("Submit answers");
3018 expect(submitButton.disabled).toBe(false);
3019 submitButton.click();
3020
3021 await vi.waitFor(
3022 () => {
3023 const dispatchRequest = wsRequests.find(
3024 (request) =>
3025 request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand &&
3026 request.type === "thread.user-input.respond",
3027 ) as
3028 | {
3029 _tag: string;
3030 type?: string;
3031 answers?: Record<string, unknown>;
3032 }
3033 | undefined;
3034
3035 expect(dispatchRequest).toMatchObject({
3036 _tag: ORCHESTRATION_WS_METHODS.dispatchCommand,
3037 type: "thread.user-input.respond",
3038 answers: {
3039 scope: ["Server", "Web"],
3040 risk: "Balanced",
3041 },
3042 });
3043 },
3044 { timeout: 8_000, interval: 16 },
3045 );
3046 } finally {
3047 await mounted.cleanup();
3048 }
3049 });
3050
2905 it("keeps plan follow-up footer actions fused and aligned after a real resize", async () => {3051 it("keeps plan follow-up footer actions fused and aligned after a real resize", async () => {
2906 const mounted = await mountChatView({3052 const mounted = await mountChatView({
2907 viewport: WIDE_FOOTER_VIEWPORT,3053 viewport: WIDE_FOOTER_VIEWPORT,
apps/web/src/components/ChatView.tsx+12-6
...@@ -61,6 +61,7 @@ import {...@@ -61,6 +61,7 @@ import {
61 buildPendingUserInputAnswers,61 buildPendingUserInputAnswers,
62 derivePendingUserInputProgress,62 derivePendingUserInputProgress,
63 setPendingUserInputCustomAnswer,63 setPendingUserInputCustomAnswer,
64 togglePendingUserInputOptionSelection,
64 type PendingUserInputDraftAnswer,65 type PendingUserInputDraftAnswer,
65} from "../pendingUserInput";66} from "../pendingUserInput";
66import { useStore } from "../store";67import { useStore } from "../store";
...@@ -3207,19 +3208,24 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3207,19 +3208,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
3207 [activePendingUserInput],3208 [activePendingUserInput],
3208 );3209 );
32093210
3210 const onSelectActivePendingUserInputOption = useCallback(3211 const onToggleActivePendingUserInputOption = useCallback(
3211 (questionId: string, optionLabel: string) => {3212 (questionId: string, optionLabel: string) => {
3212 if (!activePendingUserInput) {3213 if (!activePendingUserInput) {
3213 return;3214 return;
3214 }3215 }
3216 const question = activePendingUserInput.questions.find((entry) => entry.id === questionId);
3217 if (!question) {
3218 return;
3219 }
3215 setPendingUserInputAnswersByRequestId((existing) => ({3220 setPendingUserInputAnswersByRequestId((existing) => ({
3216 ...existing,3221 ...existing,
3217 [activePendingUserInput.requestId]: {3222 [activePendingUserInput.requestId]: {
3218 ...existing[activePendingUserInput.requestId],3223 ...existing[activePendingUserInput.requestId],
3219 [questionId]: {3224 [questionId]: togglePendingUserInputOptionSelection(
3220 selectedOptionLabel: optionLabel,3225 question,
3221 customAnswer: "",3226 existing[activePendingUserInput.requestId]?.[questionId],
3222 },3227 optionLabel,
3228 ),
3223 },3229 },
3224 }));3230 }));
3225 promptRef.current = "";3231 promptRef.current = "";
...@@ -4063,7 +4069,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -4063,7 +4069,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
4063 respondingRequestIds={respondingRequestIds}4069 respondingRequestIds={respondingRequestIds}
4064 answers={activePendingDraftAnswers}4070 answers={activePendingDraftAnswers}
4065 questionIndex={activePendingQuestionIndex}4071 questionIndex={activePendingQuestionIndex}
4066 onSelectOption={onSelectActivePendingUserInputOption}4072 onToggleOption={onToggleActivePendingUserInputOption}
4067 onAdvance={onAdvanceActivePendingUserInput}4073 onAdvance={onAdvanceActivePendingUserInput}
4068 />4074 />
4069 </div>4075 </div>
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+22-20
...@@ -13,7 +13,7 @@ interface PendingUserInputPanelProps {...@@ -13,7 +13,7 @@ interface PendingUserInputPanelProps {
13 respondingRequestIds: ApprovalRequestId[];13 respondingRequestIds: ApprovalRequestId[];
14 answers: Record<string, PendingUserInputDraftAnswer>;14 answers: Record<string, PendingUserInputDraftAnswer>;
15 questionIndex: number;15 questionIndex: number;
16 onSelectOption: (questionId: string, optionLabel: string) => void;16 onToggleOption: (questionId: string, optionLabel: string) => void;
17 onAdvance: () => void;17 onAdvance: () => void;
18}18}
1919
...@@ -22,7 +22,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn...@@ -22,7 +22,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
22 respondingRequestIds,22 respondingRequestIds,
23 answers,23 answers,
24 questionIndex,24 questionIndex,
25 onSelectOption,25 onToggleOption,
26 onAdvance,26 onAdvance,
27}: PendingUserInputPanelProps) {27}: PendingUserInputPanelProps) {
28 if (pendingUserInputs.length === 0) return null;28 if (pendingUserInputs.length === 0) return null;
...@@ -36,7 +36,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn...@@ -36,7 +36,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
36 isResponding={respondingRequestIds.includes(activePrompt.requestId)}36 isResponding={respondingRequestIds.includes(activePrompt.requestId)}
37 answers={answers}37 answers={answers}
38 questionIndex={questionIndex}38 questionIndex={questionIndex}
39 onSelectOption={onSelectOption}39 onToggleOption={onToggleOption}
40 onAdvance={onAdvance}40 onAdvance={onAdvance}
41 />41 />
42 );42 );
...@@ -47,14 +47,14 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -47,14 +47,14 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
47 isResponding,47 isResponding,
48 answers,48 answers,
49 questionIndex,49 questionIndex,
50 onSelectOption,50 onToggleOption,
51 onAdvance,51 onAdvance,
52}: {52}: {
53 prompt: PendingUserInput;53 prompt: PendingUserInput;
54 isResponding: boolean;54 isResponding: boolean;
55 answers: Record<string, PendingUserInputDraftAnswer>;55 answers: Record<string, PendingUserInputDraftAnswer>;
56 questionIndex: number;56 questionIndex: number;
57 onSelectOption: (questionId: string, optionLabel: string) => void;57 onToggleOption: (questionId: string, optionLabel: string) => void;
58 onAdvance: () => void;58 onAdvance: () => void;
59}) {59}) {
60 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);60 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
...@@ -70,9 +70,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -70,9 +70,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
70 };70 };
71 }, []);71 }, []);
7272
73 const selectOptionAndAutoAdvance = useCallback(73 const handleOptionSelection = useCallback(
74 (questionId: string, optionLabel: string) => {74 (questionId: string, optionLabel: string) => {
75 onSelectOption(questionId, optionLabel);75 onToggleOption(questionId, optionLabel);
76 if (activeQuestion?.multiSelect) {
77 return;
78 }
76 if (autoAdvanceTimerRef.current !== null) {79 if (autoAdvanceTimerRef.current !== null) {
77 window.clearTimeout(autoAdvanceTimerRef.current);80 window.clearTimeout(autoAdvanceTimerRef.current);
78 }81 }
...@@ -81,13 +84,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -81,13 +84,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
81 onAdvance();84 onAdvance();
82 }, 200);85 }, 200);
83 },86 },
84 [onSelectOption, onAdvance],87 [activeQuestion?.multiSelect, onAdvance, onToggleOption],
85 );88 );
8689
87 // Keyboard shortcut: number keys 1-9 select corresponding option and auto-advance.90 // Keyboard shortcut: number keys 1-9 select corresponding options when focus is
88 // Works even when the Lexical composer (contenteditable) has focus — the composer91 // outside editable fields. Multi-select prompts toggle options in place; single-
89 // doubles as a custom-answer field during user input, and when it's empty the digit92 // select prompts keep the existing auto-advance behavior.
90 // keys should pick options instead of typing into the editor.
91 useEffect(() => {93 useEffect(() => {
92 if (!activeQuestion || isResponding) return;94 if (!activeQuestion || isResponding) return;
93 const handler = (event: globalThis.KeyboardEvent) => {95 const handler = (event: globalThis.KeyboardEvent) => {
...@@ -96,11 +98,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -96,11 +98,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
96 if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {98 if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
97 return;99 return;
98 }100 }
99 // If the user has started typing a custom answer in the contenteditable
100 // composer, let digit keys pass through so they can type numbers.
101 if (target instanceof HTMLElement && target.isContentEditable) {101 if (target instanceof HTMLElement && target.isContentEditable) {
102 const hasCustomText = progress.customAnswer.length > 0;102 return;
103 if (hasCustomText) return;
104 }103 }
105 const digit = Number.parseInt(event.key, 10);104 const digit = Number.parseInt(event.key, 10);
106 if (Number.isNaN(digit) || digit < 1 || digit > 9) return;105 if (Number.isNaN(digit) || digit < 1 || digit > 9) return;
...@@ -109,11 +108,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -109,11 +108,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
109 const option = activeQuestion.options[optionIndex];108 const option = activeQuestion.options[optionIndex];
110 if (!option) return;109 if (!option) return;
111 event.preventDefault();110 event.preventDefault();
112 selectOptionAndAutoAdvance(activeQuestion.id, option.label);111 handleOptionSelection(activeQuestion.id, option.label);
113 };112 };
114 document.addEventListener("keydown", handler);113 document.addEventListener("keydown", handler);
115 return () => document.removeEventListener("keydown", handler);114 return () => document.removeEventListener("keydown", handler);
116 }, [activeQuestion, isResponding, selectOptionAndAutoAdvance, progress.customAnswer.length]);115 }, [activeQuestion, handleOptionSelection, isResponding]);
117116
118 if (!activeQuestion) {117 if (!activeQuestion) {
119 return null;118 return null;
...@@ -134,16 +133,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -134,16 +133,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
134 </div>133 </div>
135 </div>134 </div>
136 <p className="mt-1.5 text-sm text-foreground/90">{activeQuestion.question}</p>135 <p className="mt-1.5 text-sm text-foreground/90">{activeQuestion.question}</p>
136 {activeQuestion.multiSelect ? (
137 <p className="mt-1 text-xs text-muted-foreground/65">Select one or more options.</p>
138 ) : null}
137 <div className="mt-3 space-y-1">139 <div className="mt-3 space-y-1">
138 {activeQuestion.options.map((option, index) => {140 {activeQuestion.options.map((option, index) => {
139 const isSelected = progress.selectedOptionLabel === option.label;141 const isSelected = progress.selectedOptionLabels.includes(option.label);
140 const shortcutKey = index < 9 ? index + 1 : null;142 const shortcutKey = index < 9 ? index + 1 : null;
141 return (143 return (
142 <button144 <button
143 key={`${activeQuestion.id}:${option.label}`}145 key={`${activeQuestion.id}:${option.label}`}
144 type="button"146 type="button"
145 disabled={isResponding}147 disabled={isResponding}
146 onClick={() => selectOptionAndAutoAdvance(activeQuestion.id, option.label)}148 onClick={() => handleOptionSelection(activeQuestion.id, option.label)}
147 className={cn(149 className={cn(
148 "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-all duration-150",150 "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-all duration-150",
149 isSelected151 isSelected
apps/web/src/pendingUserInput.test.ts+113-55
...@@ -7,57 +7,109 @@ import {...@@ -7,57 +7,109 @@ import {
7 findFirstUnansweredPendingUserInputQuestionIndex,7 findFirstUnansweredPendingUserInputQuestionIndex,
8 resolvePendingUserInputAnswer,8 resolvePendingUserInputAnswer,
9 setPendingUserInputCustomAnswer,9 setPendingUserInputCustomAnswer,
10 togglePendingUserInputOptionSelection,
10} from "./pendingUserInput";11} from "./pendingUserInput";
1112
13const singleSelectQuestion = {
14 id: "scope",
15 header: "Scope",
16 question: "What should the plan target first?",
17 options: [
18 {
19 label: "Orchestration-first",
20 description: "Focus on orchestration first",
21 },
22 ],
23 multiSelect: false,
24} as const;
25
26const multiSelectQuestion = {
27 id: "areas",
28 header: "Areas",
29 question: "Which areas should this change cover?",
30 options: [
31 {
32 label: "Server",
33 description: "Server",
34 },
35 {
36 label: "Web",
37 description: "Web",
38 },
39 ],
40 multiSelect: true,
41} as const;
42
12describe("resolvePendingUserInputAnswer", () => {43describe("resolvePendingUserInputAnswer", () => {
13 it("prefers a custom answer over a selected option", () => {44 it("prefers a custom answer over selected options", () => {
14 expect(45 expect(
15 resolvePendingUserInputAnswer({46 resolvePendingUserInputAnswer(singleSelectQuestion, {
16 selectedOptionLabel: "Keep current envelope",47 selectedOptionLabels: ["Orchestration-first"],
17 customAnswer: "Keep the existing envelope for one release",48 customAnswer: "Keep the existing envelope for one release",
18 }),49 }),
19 ).toBe("Keep the existing envelope for one release");50 ).toBe("Keep the existing envelope for one release");
20 });51 });
2152
22 it("falls back to the selected option", () => {53 it("falls back to the selected option for single-select questions", () => {
23 expect(54 expect(
24 resolvePendingUserInputAnswer({55 resolvePendingUserInputAnswer(singleSelectQuestion, {
25 selectedOptionLabel: "Scaffold only",56 selectedOptionLabels: ["Orchestration-first"],
26 }),57 }),
27 ).toBe("Scaffold only");58 ).toBe("Orchestration-first");
59 });
60
61 it("returns all selected labels for multi-select questions", () => {
62 expect(
63 resolvePendingUserInputAnswer(multiSelectQuestion, {
64 selectedOptionLabels: ["Server", "Web"],
65 }),
66 ).toEqual(["Server", "Web"]);
28 });67 });
2968
30 it("clears the preset selection when a custom answer is entered", () => {69 it("clears the preset selection when a custom answer is entered", () => {
31 expect(70 expect(
32 setPendingUserInputCustomAnswer(71 setPendingUserInputCustomAnswer(
33 {72 {
34 selectedOptionLabel: "Preserve existing tags",73 selectedOptionLabels: ["Server", "Web"],
35 },74 },
36 "doesn't matter",75 "doesn't matter",
37 ),76 ),
38 ).toEqual({77 ).toEqual({
39 selectedOptionLabel: undefined,
40 customAnswer: "doesn't matter",78 customAnswer: "doesn't matter",
41 });79 });
42 });80 });
43});81});
4482
83describe("togglePendingUserInputOptionSelection", () => {
84 it("toggles options for multi-select questions", () => {
85 expect(togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Server")).toEqual(
86 {
87 customAnswer: "",
88 selectedOptionLabels: ["Server"],
89 },
90 );
91
92 expect(
93 togglePendingUserInputOptionSelection(
94 multiSelectQuestion,
95 {
96 selectedOptionLabels: ["Server", "Web"],
97 },
98 "Server",
99 ),
100 ).toEqual({
101 customAnswer: "",
102 selectedOptionLabels: ["Web"],
103 });
104 });
105});
106
45describe("buildPendingUserInputAnswers", () => {107describe("buildPendingUserInputAnswers", () => {
46 it("returns a canonical answer map for complete prompts", () => {108 it("returns a canonical answer map for complete prompts", () => {
47 expect(109 expect(
48 buildPendingUserInputAnswers(110 buildPendingUserInputAnswers(
49 [111 [
50 {112 singleSelectQuestion,
51 id: "scope",
52 header: "Scope",
53 question: "What should the plan target first?",
54 options: [
55 {
56 label: "Orchestration-first",
57 description: "Focus on orchestration first",
58 },
59 ],
60 },
61 {113 {
62 id: "compat",114 id: "compat",
63 header: "Compat",115 header: "Compat",
...@@ -68,11 +120,12 @@ describe("buildPendingUserInputAnswers", () => {...@@ -68,11 +120,12 @@ describe("buildPendingUserInputAnswers", () => {
68 description: "Preserve current wire format",120 description: "Preserve current wire format",
69 },121 },
70 ],122 ],
123 multiSelect: false,
71 },124 },
72 ],125 ],
73 {126 {
74 scope: {127 scope: {
75 selectedOptionLabel: "Orchestration-first",128 selectedOptionLabels: ["Orchestration-first"],
76 },129 },
77 compat: {130 compat: {
78 customAnswer: "Keep the current envelope for one release window",131 customAnswer: "Keep the current envelope for one release window",
...@@ -85,41 +138,26 @@ describe("buildPendingUserInputAnswers", () => {...@@ -85,41 +138,26 @@ describe("buildPendingUserInputAnswers", () => {
85 });138 });
86 });139 });
87140
88 it("returns null when any question is unanswered", () => {141 it("returns arrays for answered multi-select prompts", () => {
89 expect(142 expect(
90 buildPendingUserInputAnswers(143 buildPendingUserInputAnswers([multiSelectQuestion], {
91 [144 areas: {
92 {145 selectedOptionLabels: ["Server", "Web"],
93 id: "scope",146 },
94 header: "Scope",147 }),
95 question: "What should the plan target first?",148 ).toEqual({
96 options: [149 areas: ["Server", "Web"],
97 {150 });
98 label: "Orchestration-first",151 });
99 description: "Focus on orchestration first",152
100 },153 it("returns null when any question is unanswered", () => {
101 ],154 expect(buildPendingUserInputAnswers([singleSelectQuestion], {})).toBeNull();
102 },
103 ],
104 {},
105 ),
106 ).toBeNull();
107 });155 });
108});156});
109157
110describe("pending user input question progress", () => {158describe("pending user input question progress", () => {
111 const questions = [159 const questions = [
112 {160 singleSelectQuestion,
113 id: "scope",
114 header: "Scope",
115 question: "What should the plan target first?",
116 options: [
117 {
118 label: "Orchestration-first",
119 description: "Focus on orchestration first",
120 },
121 ],
122 },
123 {161 {
124 id: "compat",162 id: "compat",
125 header: "Compat",163 header: "Compat",
...@@ -130,6 +168,7 @@ describe("pending user input question progress", () => {...@@ -130,6 +168,7 @@ describe("pending user input question progress", () => {
130 description: "Preserve current wire format",168 description: "Preserve current wire format",
131 },169 },
132 ],170 ],
171 multiSelect: false,
133 },172 },
134 ] as const;173 ] as const;
135174
...@@ -137,7 +176,7 @@ describe("pending user input question progress", () => {...@@ -137,7 +176,7 @@ describe("pending user input question progress", () => {
137 expect(176 expect(
138 countAnsweredPendingUserInputQuestions(questions, {177 countAnsweredPendingUserInputQuestions(questions, {
139 scope: {178 scope: {
140 selectedOptionLabel: "Orchestration-first",179 selectedOptionLabels: ["Orchestration-first"],
141 },180 },
142 }),181 }),
143 ).toBe(1);182 ).toBe(1);
...@@ -147,7 +186,7 @@ describe("pending user input question progress", () => {...@@ -147,7 +186,7 @@ describe("pending user input question progress", () => {
147 expect(186 expect(
148 findFirstUnansweredPendingUserInputQuestionIndex(questions, {187 findFirstUnansweredPendingUserInputQuestionIndex(questions, {
149 scope: {188 scope: {
150 selectedOptionLabel: "Orchestration-first",189 selectedOptionLabels: ["Orchestration-first"],
151 },190 },
152 }),191 }),
153 ).toBe(1);192 ).toBe(1);
...@@ -157,7 +196,7 @@ describe("pending user input question progress", () => {...@@ -157,7 +196,7 @@ describe("pending user input question progress", () => {
157 expect(196 expect(
158 findFirstUnansweredPendingUserInputQuestionIndex(questions, {197 findFirstUnansweredPendingUserInputQuestionIndex(questions, {
159 scope: {198 scope: {
160 selectedOptionLabel: "Orchestration-first",199 selectedOptionLabels: ["Orchestration-first"],
161 },200 },
162 compat: {201 compat: {
163 customAnswer: "Keep it for one release window",202 customAnswer: "Keep it for one release window",
...@@ -172,7 +211,7 @@ describe("pending user input question progress", () => {...@@ -172,7 +211,7 @@ describe("pending user input question progress", () => {
172 questions,211 questions,
173 {212 {
174 scope: {213 scope: {
175 selectedOptionLabel: "Orchestration-first",214 selectedOptionLabels: ["Orchestration-first"],
176 },215 },
177 },216 },
178 0,217 0,
...@@ -180,7 +219,7 @@ describe("pending user input question progress", () => {...@@ -180,7 +219,7 @@ describe("pending user input question progress", () => {
180 ).toMatchObject({219 ).toMatchObject({
181 questionIndex: 0,220 questionIndex: 0,
182 activeQuestion: questions[0],221 activeQuestion: questions[0],
183 selectedOptionLabel: "Orchestration-first",222 selectedOptionLabels: ["Orchestration-first"],
184 customAnswer: "",223 customAnswer: "",
185 resolvedAnswer: "Orchestration-first",224 resolvedAnswer: "Orchestration-first",
186 answeredQuestionCount: 1,225 answeredQuestionCount: 1,
...@@ -189,4 +228,23 @@ describe("pending user input question progress", () => {...@@ -189,4 +228,23 @@ describe("pending user input question progress", () => {
189 canAdvance: true,228 canAdvance: true,
190 });229 });
191 });230 });
231
232 it("treats multi-select questions as answered when they have selected options", () => {
233 expect(
234 derivePendingUserInputProgress(
235 [multiSelectQuestion],
236 {
237 areas: {
238 selectedOptionLabels: ["Server", "Web"],
239 },
240 },
241 0,
242 ),
243 ).toMatchObject({
244 selectedOptionLabels: ["Server", "Web"],
245 resolvedAnswer: ["Server", "Web"],
246 canAdvance: true,
247 isComplete: true,
248 });
249 });
192});250});
apps/web/src/pendingUserInput.ts+63-15
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import type { UserInputQuestion } from "@t3tools/contracts";1import type { UserInputQuestion } from "@t3tools/contracts";
22
3export interface PendingUserInputDraftAnswer {3export interface PendingUserInputDraftAnswer {
4 selectedOptionLabel?: string;4 selectedOptionLabels?: string[];
5 customAnswer?: string;5 customAnswer?: string;
6}6}
77
...@@ -9,9 +9,9 @@ export interface PendingUserInputProgress {...@@ -9,9 +9,9 @@ export interface PendingUserInputProgress {
9 questionIndex: number;9 questionIndex: number;
10 activeQuestion: UserInputQuestion | null;10 activeQuestion: UserInputQuestion | null;
11 activeDraft: PendingUserInputDraftAnswer | undefined;11 activeDraft: PendingUserInputDraftAnswer | undefined;
12 selectedOptionLabel: string | undefined;12 selectedOptionLabels: string[];
13 customAnswer: string;13 customAnswer: string;
14 resolvedAnswer: string | null;14 resolvedAnswer: string | string[] | null;
15 usingCustomAnswer: boolean;15 usingCustomAnswer: boolean;
16 answeredQuestionCount: number;16 answeredQuestionCount: number;
17 isLastQuestion: boolean;17 isLastQuestion: boolean;
...@@ -28,38 +28,84 @@ function normalizeDraftAnswer(value: string | undefined): string | null {...@@ -28,38 +28,84 @@ function normalizeDraftAnswer(value: string | undefined): string | null {
28 return trimmed.length > 0 ? trimmed : null;28 return trimmed.length > 0 ? trimmed : null;
29}29}
3030
31function normalizeSelectedOptionLabels(value: string[] | undefined): string[] {
32 if (!Array.isArray(value)) {
33 return [];
34 }
35
36 const normalized = value
37 .filter((entry): entry is string => typeof entry === "string")
38 .map((entry) => entry.trim())
39 .filter((entry) => entry.length > 0);
40
41 return Array.from(new Set(normalized));
42}
43
31export function resolvePendingUserInputAnswer(44export function resolvePendingUserInputAnswer(
45 question: UserInputQuestion,
32 draft: PendingUserInputDraftAnswer | undefined,46 draft: PendingUserInputDraftAnswer | undefined,
33): string | null {47): string | string[] | null {
34 const customAnswer = normalizeDraftAnswer(draft?.customAnswer);48 const customAnswer = normalizeDraftAnswer(draft?.customAnswer);
35 if (customAnswer) {49 if (customAnswer) {
36 return customAnswer;50 return customAnswer;
37 }51 }
3852
39 return normalizeDraftAnswer(draft?.selectedOptionLabel);53 const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels);
54 if (question.multiSelect) {
55 return selectedOptionLabels.length > 0 ? selectedOptionLabels : null;
56 }
57
58 return selectedOptionLabels[0] ?? null;
40}59}
4160
42export function setPendingUserInputCustomAnswer(61export function setPendingUserInputCustomAnswer(
43 draft: PendingUserInputDraftAnswer | undefined,62 draft: PendingUserInputDraftAnswer | undefined,
44 customAnswer: string,63 customAnswer: string,
45): PendingUserInputDraftAnswer {64): PendingUserInputDraftAnswer {
46 const selectedOptionLabel =65 const selectedOptionLabels =
47 customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel;66 customAnswer.trim().length > 0
67 ? undefined
68 : normalizeSelectedOptionLabels(draft?.selectedOptionLabels);
4869
49 return {70 return {
50 customAnswer,71 customAnswer,
51 ...(selectedOptionLabel ? { selectedOptionLabel } : {}),72 ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}),
73 };
74}
75
76export function togglePendingUserInputOptionSelection(
77 question: UserInputQuestion,
78 draft: PendingUserInputDraftAnswer | undefined,
79 optionLabel: string,
80): PendingUserInputDraftAnswer {
81 if (question.multiSelect) {
82 const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels);
83 const nextSelectedOptionLabels = selectedOptionLabels.includes(optionLabel)
84 ? selectedOptionLabels.filter((label) => label !== optionLabel)
85 : [...selectedOptionLabels, optionLabel];
86
87 return {
88 customAnswer: "",
89 ...(nextSelectedOptionLabels.length > 0
90 ? { selectedOptionLabels: nextSelectedOptionLabels }
91 : {}),
92 };
93 }
94
95 return {
96 customAnswer: "",
97 selectedOptionLabels: [optionLabel],
52 };98 };
53}99}
54100
55export function buildPendingUserInputAnswers(101export function buildPendingUserInputAnswers(
56 questions: ReadonlyArray<UserInputQuestion>,102 questions: ReadonlyArray<UserInputQuestion>,
57 draftAnswers: Record<string, PendingUserInputDraftAnswer>,103 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
58): Record<string, string> | null {104): Record<string, string | string[]> | null {
59 const answers: Record<string, string> = {};105 const answers: Record<string, string | string[]> = {};
60106
61 for (const question of questions) {107 for (const question of questions) {
62 const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]);108 const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]);
63 if (!answer) {109 if (!answer) {
64 return null;110 return null;
65 }111 }
...@@ -74,7 +120,7 @@ export function countAnsweredPendingUserInputQuestions(...@@ -74,7 +120,7 @@ export function countAnsweredPendingUserInputQuestions(
74 draftAnswers: Record<string, PendingUserInputDraftAnswer>,120 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
75): number {121): number {
76 return questions.reduce((count, question) => {122 return questions.reduce((count, question) => {
77 return resolvePendingUserInputAnswer(draftAnswers[question.id]) ? count + 1 : count;123 return resolvePendingUserInputAnswer(question, draftAnswers[question.id]) ? count + 1 : count;
78 }, 0);124 }, 0);
79}125}
80126
...@@ -83,7 +129,7 @@ export function findFirstUnansweredPendingUserInputQuestionIndex(...@@ -83,7 +129,7 @@ export function findFirstUnansweredPendingUserInputQuestionIndex(
83 draftAnswers: Record<string, PendingUserInputDraftAnswer>,129 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
84): number {130): number {
85 const unansweredIndex = questions.findIndex(131 const unansweredIndex = questions.findIndex(
86 (question) => !resolvePendingUserInputAnswer(draftAnswers[question.id]),132 (question) => !resolvePendingUserInputAnswer(question, draftAnswers[question.id]),
87 );133 );
88134
89 return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex;135 return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex;
...@@ -98,7 +144,9 @@ export function derivePendingUserInputProgress(...@@ -98,7 +144,9 @@ export function derivePendingUserInputProgress(
98 questions.length === 0 ? 0 : Math.max(0, Math.min(questionIndex, questions.length - 1));144 questions.length === 0 ? 0 : Math.max(0, Math.min(questionIndex, questions.length - 1));
99 const activeQuestion = questions[normalizedQuestionIndex] ?? null;145 const activeQuestion = questions[normalizedQuestionIndex] ?? null;
100 const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined;146 const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined;
101 const resolvedAnswer = resolvePendingUserInputAnswer(activeDraft);147 const resolvedAnswer = activeQuestion
148 ? resolvePendingUserInputAnswer(activeQuestion, activeDraft)
149 : null;
102 const customAnswer = activeDraft?.customAnswer ?? "";150 const customAnswer = activeDraft?.customAnswer ?? "";
103 const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers);151 const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers);
104 const isLastQuestion =152 const isLastQuestion =
...@@ -108,7 +156,7 @@ export function derivePendingUserInputProgress(...@@ -108,7 +156,7 @@ export function derivePendingUserInputProgress(
108 questionIndex: normalizedQuestionIndex,156 questionIndex: normalizedQuestionIndex,
109 activeQuestion,157 activeQuestion,
110 activeDraft,158 activeDraft,
111 selectedOptionLabel: activeDraft?.selectedOptionLabel,159 selectedOptionLabels: normalizeSelectedOptionLabels(activeDraft?.selectedOptionLabels),
112 customAnswer,160 customAnswer,
113 resolvedAnswer,161 resolvedAnswer,
114 usingCustomAnswer: customAnswer.trim().length > 0,162 usingCustomAnswer: customAnswer.trim().length > 0,
apps/web/src/session-logic.test.ts+4
...@@ -197,6 +197,7 @@ describe("derivePendingUserInputs", () => {...@@ -197,6 +197,7 @@ describe("derivePendingUserInputs", () => {
197 description: "Allow workspace writes only",197 description: "Allow workspace writes only",
198 },198 },
199 ],199 ],
200 multiSelect: true,
200 },201 },
201 ],202 ],
202 },203 },
...@@ -233,6 +234,7 @@ describe("derivePendingUserInputs", () => {...@@ -233,6 +234,7 @@ describe("derivePendingUserInputs", () => {
233 description: "Continue execution",234 description: "Continue execution",
234 },235 },
235 ],236 ],
237 multiSelect: false,
236 },238 },
237 ],239 ],
238 },240 },
...@@ -254,6 +256,7 @@ describe("derivePendingUserInputs", () => {...@@ -254,6 +256,7 @@ describe("derivePendingUserInputs", () => {
254 description: "Allow workspace writes only",256 description: "Allow workspace writes only",
255 },257 },
256 ],258 ],
259 multiSelect: true,
257 },260 },
258 ],261 ],
259 },262 },
...@@ -281,6 +284,7 @@ describe("derivePendingUserInputs", () => {...@@ -281,6 +284,7 @@ describe("derivePendingUserInputs", () => {
281 description: "Allow workspace writes only",284 description: "Allow workspace writes only",
282 },285 },
283 ],286 ],
287 multiSelect: false,
284 },288 },
285 ],289 ],
286 },290 },
apps/web/src/session-logic.ts+1
...@@ -279,6 +279,7 @@ function parseUserInputQuestions(...@@ -279,6 +279,7 @@ function parseUserInputQuestions(
279 header: question.header,279 header: question.header,
280 question: question.question,280 question: question.question,
281 options,281 options,
282 multiSelect: question.multiSelect === true,
282 };283 };
283 })284 })
284 .filter((question): question is UserInputQuestion => question !== null);285 .filter((question): question is UserInputQuestion => question !== null);