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) => {
723723 description: "Allow workspace writes only",
724724 },
725725 ],
726 multiSelect: true,
726727 },
727728 ],
728729 },
......@@ -749,6 +750,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
749750 if (events[0]?.type === "user-input.requested") {
750751 assert.equal(events[0].requestId, "req-user-input-1");
751752 assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode");
753 assert.equal(events[0].payload.questions[0]?.multiSelect, true);
752754 }
753755
754756 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) {
382382 header,
383383 question: prompt,
384384 options,
385 multiSelect: question.multiSelect === true,
385386 };
386387 })
387388 .filter(
......@@ -392,6 +393,7 @@ function toUserInputQuestions(payload: Record<string, unknown> | undefined) {
392393 header: string;
393394 question: string;
394395 options: Array<{ label: string; description: string }>;
396 multiSelect: boolean;
395397 } => question !== undefined,
396398 );
397399
apps/web/src/components/ChatView.browser.tsx+179-33
......@@ -12,6 +12,7 @@ import {
1212 type ServerLifecycleWelcomePayload,
1313 type ThreadId,
1414 type TurnId,
15 type UserInputQuestion,
1516 WS_METHODS,
1617 OrchestrationSessionStatus,
1718 DEFAULT_SERVER_SETTINGS,
......@@ -541,12 +542,51 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
541542 };
542543}
543544
544function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
545function createSnapshotWithPendingUserInput(options?: {
546 questions?: ReadonlyArray<UserInputQuestion>;
547}): OrchestrationReadModel {
545548 const snapshot = createSnapshotForTargetUser({
546549 targetMessageId: "msg-user-pending-input-target" as MessageId,
547550 targetText: "question thread",
548551 });
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
550590 return {
551591 ...snapshot,
552592 threads: snapshot.threads.map((thread) =>
......@@ -561,38 +601,7 @@ function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
561601 summary: "User input requested",
562602 payload: {
563603 requestId: "req-browser-user-input",
564 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 ],
604 questions,
596605 },
597606 turnId: null,
598607 sequence: 1,
......@@ -2902,6 +2911,143 @@ describe("ChatView timeline estimator parity (full app)", () => {
29022911 }
29032912 });
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
29053051 it("keeps plan follow-up footer actions fused and aligned after a real resize", async () => {
29063052 const mounted = await mountChatView({
29073053 viewport: WIDE_FOOTER_VIEWPORT,
apps/web/src/components/ChatView.tsx+12-6
......@@ -61,6 +61,7 @@ import {
6161 buildPendingUserInputAnswers,
6262 derivePendingUserInputProgress,
6363 setPendingUserInputCustomAnswer,
64 togglePendingUserInputOptionSelection,
6465 type PendingUserInputDraftAnswer,
6566} from "../pendingUserInput";
6667import { useStore } from "../store";
......@@ -3207,19 +3208,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
32073208 [activePendingUserInput],
32083209 );
32093210
3210 const onSelectActivePendingUserInputOption = useCallback(
3211 const onToggleActivePendingUserInputOption = useCallback(
32113212 (questionId: string, optionLabel: string) => {
32123213 if (!activePendingUserInput) {
32133214 return;
32143215 }
3216 const question = activePendingUserInput.questions.find((entry) => entry.id === questionId);
3217 if (!question) {
3218 return;
3219 }
32153220 setPendingUserInputAnswersByRequestId((existing) => ({
32163221 ...existing,
32173222 [activePendingUserInput.requestId]: {
32183223 ...existing[activePendingUserInput.requestId],
3219 [questionId]: {
3220 selectedOptionLabel: optionLabel,
3221 customAnswer: "",
3222 },
3224 [questionId]: togglePendingUserInputOptionSelection(
3225 question,
3226 existing[activePendingUserInput.requestId]?.[questionId],
3227 optionLabel,
3228 ),
32233229 },
32243230 }));
32253231 promptRef.current = "";
......@@ -4063,7 +4069,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
40634069 respondingRequestIds={respondingRequestIds}
40644070 answers={activePendingDraftAnswers}
40654071 questionIndex={activePendingQuestionIndex}
4066 onSelectOption={onSelectActivePendingUserInputOption}
4072 onToggleOption={onToggleActivePendingUserInputOption}
40674073 onAdvance={onAdvanceActivePendingUserInput}
40684074 />
40694075 </div>
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+22-20
......@@ -13,7 +13,7 @@ interface PendingUserInputPanelProps {
1313 respondingRequestIds: ApprovalRequestId[];
1414 answers: Record<string, PendingUserInputDraftAnswer>;
1515 questionIndex: number;
16 onSelectOption: (questionId: string, optionLabel: string) => void;
16 onToggleOption: (questionId: string, optionLabel: string) => void;
1717 onAdvance: () => void;
1818}
1919
......@@ -22,7 +22,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
2222 respondingRequestIds,
2323 answers,
2424 questionIndex,
25 onSelectOption,
25 onToggleOption,
2626 onAdvance,
2727}: PendingUserInputPanelProps) {
2828 if (pendingUserInputs.length === 0) return null;
......@@ -36,7 +36,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
3636 isResponding={respondingRequestIds.includes(activePrompt.requestId)}
3737 answers={answers}
3838 questionIndex={questionIndex}
39 onSelectOption={onSelectOption}
39 onToggleOption={onToggleOption}
4040 onAdvance={onAdvance}
4141 />
4242 );
......@@ -47,14 +47,14 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
4747 isResponding,
4848 answers,
4949 questionIndex,
50 onSelectOption,
50 onToggleOption,
5151 onAdvance,
5252}: {
5353 prompt: PendingUserInput;
5454 isResponding: boolean;
5555 answers: Record<string, PendingUserInputDraftAnswer>;
5656 questionIndex: number;
57 onSelectOption: (questionId: string, optionLabel: string) => void;
57 onToggleOption: (questionId: string, optionLabel: string) => void;
5858 onAdvance: () => void;
5959}) {
6060 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
......@@ -70,9 +70,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
7070 };
7171 }, []);
7272
73 const selectOptionAndAutoAdvance = useCallback(
73 const handleOptionSelection = useCallback(
7474 (questionId: string, optionLabel: string) => {
75 onSelectOption(questionId, optionLabel);
75 onToggleOption(questionId, optionLabel);
76 if (activeQuestion?.multiSelect) {
77 return;
78 }
7679 if (autoAdvanceTimerRef.current !== null) {
7780 window.clearTimeout(autoAdvanceTimerRef.current);
7881 }
......@@ -81,13 +84,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
8184 onAdvance();
8285 }, 200);
8386 },
84 [onSelectOption, onAdvance],
87 [activeQuestion?.multiSelect, onAdvance, onToggleOption],
8588 );
8689
87 // Keyboard shortcut: number keys 1-9 select corresponding option and auto-advance.
88 // Works even when the Lexical composer (contenteditable) has focus — the composer
89 // doubles as a custom-answer field during user input, and when it's empty the digit
90 // keys should pick options instead of typing into the editor.
90 // Keyboard shortcut: number keys 1-9 select corresponding options when focus is
91 // outside editable fields. Multi-select prompts toggle options in place; single-
92 // select prompts keep the existing auto-advance behavior.
9193 useEffect(() => {
9294 if (!activeQuestion || isResponding) return;
9395 const handler = (event: globalThis.KeyboardEvent) => {
......@@ -96,11 +98,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
9698 if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
9799 return;
98100 }
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.
101101 if (target instanceof HTMLElement && target.isContentEditable) {
102 const hasCustomText = progress.customAnswer.length > 0;
103 if (hasCustomText) return;
102 return;
104103 }
105104 const digit = Number.parseInt(event.key, 10);
106105 if (Number.isNaN(digit) || digit < 1 || digit > 9) return;
......@@ -109,11 +108,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
109108 const option = activeQuestion.options[optionIndex];
110109 if (!option) return;
111110 event.preventDefault();
112 selectOptionAndAutoAdvance(activeQuestion.id, option.label);
111 handleOptionSelection(activeQuestion.id, option.label);
113112 };
114113 document.addEventListener("keydown", handler);
115114 return () => document.removeEventListener("keydown", handler);
116 }, [activeQuestion, isResponding, selectOptionAndAutoAdvance, progress.customAnswer.length]);
115 }, [activeQuestion, handleOptionSelection, isResponding]);
117116
118117 if (!activeQuestion) {
119118 return null;
......@@ -134,16 +133,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
134133 </div>
135134 </div>
136135 <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}
137139 <div className="mt-3 space-y-1">
138140 {activeQuestion.options.map((option, index) => {
139 const isSelected = progress.selectedOptionLabel === option.label;
141 const isSelected = progress.selectedOptionLabels.includes(option.label);
140142 const shortcutKey = index < 9 ? index + 1 : null;
141143 return (
142144 <button
143145 key={`${activeQuestion.id}:${option.label}`}
144146 type="button"
145147 disabled={isResponding}
146 onClick={() => selectOptionAndAutoAdvance(activeQuestion.id, option.label)}
148 onClick={() => handleOptionSelection(activeQuestion.id, option.label)}
147149 className={cn(
148150 "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-all duration-150",
149151 isSelected
apps/web/src/pendingUserInput.test.ts+113-55
......@@ -7,57 +7,109 @@ import {
77 findFirstUnansweredPendingUserInputQuestionIndex,
88 resolvePendingUserInputAnswer,
99 setPendingUserInputCustomAnswer,
10 togglePendingUserInputOptionSelection,
1011} 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
1243describe("resolvePendingUserInputAnswer", () => {
13 it("prefers a custom answer over a selected option", () => {
44 it("prefers a custom answer over selected options", () => {
1445 expect(
15 resolvePendingUserInputAnswer({
16 selectedOptionLabel: "Keep current envelope",
46 resolvePendingUserInputAnswer(singleSelectQuestion, {
47 selectedOptionLabels: ["Orchestration-first"],
1748 customAnswer: "Keep the existing envelope for one release",
1849 }),
1950 ).toBe("Keep the existing envelope for one release");
2051 });
2152
22 it("falls back to the selected option", () => {
53 it("falls back to the selected option for single-select questions", () => {
2354 expect(
24 resolvePendingUserInputAnswer({
25 selectedOptionLabel: "Scaffold only",
55 resolvePendingUserInputAnswer(singleSelectQuestion, {
56 selectedOptionLabels: ["Orchestration-first"],
2657 }),
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"]);
2867 });
2968
3069 it("clears the preset selection when a custom answer is entered", () => {
3170 expect(
3271 setPendingUserInputCustomAnswer(
3372 {
34 selectedOptionLabel: "Preserve existing tags",
73 selectedOptionLabels: ["Server", "Web"],
3574 },
3675 "doesn't matter",
3776 ),
3877 ).toEqual({
39 selectedOptionLabel: undefined,
4078 customAnswer: "doesn't matter",
4179 });
4280 });
4381});
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
45107describe("buildPendingUserInputAnswers", () => {
46108 it("returns a canonical answer map for complete prompts", () => {
47109 expect(
48110 buildPendingUserInputAnswers(
49111 [
50 {
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 },
112 singleSelectQuestion,
61113 {
62114 id: "compat",
63115 header: "Compat",
......@@ -68,11 +120,12 @@ describe("buildPendingUserInputAnswers", () => {
68120 description: "Preserve current wire format",
69121 },
70122 ],
123 multiSelect: false,
71124 },
72125 ],
73126 {
74127 scope: {
75 selectedOptionLabel: "Orchestration-first",
128 selectedOptionLabels: ["Orchestration-first"],
76129 },
77130 compat: {
78131 customAnswer: "Keep the current envelope for one release window",
......@@ -85,41 +138,26 @@ describe("buildPendingUserInputAnswers", () => {
85138 });
86139 });
87140
88 it("returns null when any question is unanswered", () => {
141 it("returns arrays for answered multi-select prompts", () => {
89142 expect(
90 buildPendingUserInputAnswers(
91 [
92 {
93 id: "scope",
94 header: "Scope",
95 question: "What should the plan target first?",
96 options: [
97 {
98 label: "Orchestration-first",
99 description: "Focus on orchestration first",
100 },
101 ],
102 },
103 ],
104 {},
105 ),
106 ).toBeNull();
143 buildPendingUserInputAnswers([multiSelectQuestion], {
144 areas: {
145 selectedOptionLabels: ["Server", "Web"],
146 },
147 }),
148 ).toEqual({
149 areas: ["Server", "Web"],
150 });
151 });
152
153 it("returns null when any question is unanswered", () => {
154 expect(buildPendingUserInputAnswers([singleSelectQuestion], {})).toBeNull();
107155 });
108156});
109157
110158describe("pending user input question progress", () => {
111159 const questions = [
112 {
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 },
160 singleSelectQuestion,
123161 {
124162 id: "compat",
125163 header: "Compat",
......@@ -130,6 +168,7 @@ describe("pending user input question progress", () => {
130168 description: "Preserve current wire format",
131169 },
132170 ],
171 multiSelect: false,
133172 },
134173 ] as const;
135174
......@@ -137,7 +176,7 @@ describe("pending user input question progress", () => {
137176 expect(
138177 countAnsweredPendingUserInputQuestions(questions, {
139178 scope: {
140 selectedOptionLabel: "Orchestration-first",
179 selectedOptionLabels: ["Orchestration-first"],
141180 },
142181 }),
143182 ).toBe(1);
......@@ -147,7 +186,7 @@ describe("pending user input question progress", () => {
147186 expect(
148187 findFirstUnansweredPendingUserInputQuestionIndex(questions, {
149188 scope: {
150 selectedOptionLabel: "Orchestration-first",
189 selectedOptionLabels: ["Orchestration-first"],
151190 },
152191 }),
153192 ).toBe(1);
......@@ -157,7 +196,7 @@ describe("pending user input question progress", () => {
157196 expect(
158197 findFirstUnansweredPendingUserInputQuestionIndex(questions, {
159198 scope: {
160 selectedOptionLabel: "Orchestration-first",
199 selectedOptionLabels: ["Orchestration-first"],
161200 },
162201 compat: {
163202 customAnswer: "Keep it for one release window",
......@@ -172,7 +211,7 @@ describe("pending user input question progress", () => {
172211 questions,
173212 {
174213 scope: {
175 selectedOptionLabel: "Orchestration-first",
214 selectedOptionLabels: ["Orchestration-first"],
176215 },
177216 },
178217 0,
......@@ -180,7 +219,7 @@ describe("pending user input question progress", () => {
180219 ).toMatchObject({
181220 questionIndex: 0,
182221 activeQuestion: questions[0],
183 selectedOptionLabel: "Orchestration-first",
222 selectedOptionLabels: ["Orchestration-first"],
184223 customAnswer: "",
185224 resolvedAnswer: "Orchestration-first",
186225 answeredQuestionCount: 1,
......@@ -189,4 +228,23 @@ describe("pending user input question progress", () => {
189228 canAdvance: true,
190229 });
191230 });
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 });
192250});
apps/web/src/pendingUserInput.ts+63-15
......@@ -1,7 +1,7 @@
11import type { UserInputQuestion } from "@t3tools/contracts";
22
33export interface PendingUserInputDraftAnswer {
4 selectedOptionLabel?: string;
4 selectedOptionLabels?: string[];
55 customAnswer?: string;
66}
77
......@@ -9,9 +9,9 @@ export interface PendingUserInputProgress {
99 questionIndex: number;
1010 activeQuestion: UserInputQuestion | null;
1111 activeDraft: PendingUserInputDraftAnswer | undefined;
12 selectedOptionLabel: string | undefined;
12 selectedOptionLabels: string[];
1313 customAnswer: string;
14 resolvedAnswer: string | null;
14 resolvedAnswer: string | string[] | null;
1515 usingCustomAnswer: boolean;
1616 answeredQuestionCount: number;
1717 isLastQuestion: boolean;
......@@ -28,38 +28,84 @@ function normalizeDraftAnswer(value: string | undefined): string | null {
2828 return trimmed.length > 0 ? trimmed : null;
2929}
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
3144export function resolvePendingUserInputAnswer(
45 question: UserInputQuestion,
3246 draft: PendingUserInputDraftAnswer | undefined,
33): string | null {
47): string | string[] | null {
3448 const customAnswer = normalizeDraftAnswer(draft?.customAnswer);
3549 if (customAnswer) {
3650 return customAnswer;
3751 }
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;
4059}
4160
4261export function setPendingUserInputCustomAnswer(
4362 draft: PendingUserInputDraftAnswer | undefined,
4463 customAnswer: string,
4564): PendingUserInputDraftAnswer {
46 const selectedOptionLabel =
47 customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel;
65 const selectedOptionLabels =
66 customAnswer.trim().length > 0
67 ? undefined
68 : normalizeSelectedOptionLabels(draft?.selectedOptionLabels);
4869
4970 return {
5071 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],
5298 };
5399}
54100
55101export function buildPendingUserInputAnswers(
56102 questions: ReadonlyArray<UserInputQuestion>,
57103 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
58): Record<string, string> | null {
59 const answers: Record<string, string> = {};
104): Record<string, string | string[]> | null {
105 const answers: Record<string, string | string[]> = {};
60106
61107 for (const question of questions) {
62 const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]);
108 const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]);
63109 if (!answer) {
64110 return null;
65111 }
......@@ -74,7 +120,7 @@ export function countAnsweredPendingUserInputQuestions(
74120 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
75121): number {
76122 return questions.reduce((count, question) => {
77 return resolvePendingUserInputAnswer(draftAnswers[question.id]) ? count + 1 : count;
123 return resolvePendingUserInputAnswer(question, draftAnswers[question.id]) ? count + 1 : count;
78124 }, 0);
79125}
80126
......@@ -83,7 +129,7 @@ export function findFirstUnansweredPendingUserInputQuestionIndex(
83129 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
84130): number {
85131 const unansweredIndex = questions.findIndex(
86 (question) => !resolvePendingUserInputAnswer(draftAnswers[question.id]),
132 (question) => !resolvePendingUserInputAnswer(question, draftAnswers[question.id]),
87133 );
88134
89135 return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex;
......@@ -98,7 +144,9 @@ export function derivePendingUserInputProgress(
98144 questions.length === 0 ? 0 : Math.max(0, Math.min(questionIndex, questions.length - 1));
99145 const activeQuestion = questions[normalizedQuestionIndex] ?? null;
100146 const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined;
101 const resolvedAnswer = resolvePendingUserInputAnswer(activeDraft);
147 const resolvedAnswer = activeQuestion
148 ? resolvePendingUserInputAnswer(activeQuestion, activeDraft)
149 : null;
102150 const customAnswer = activeDraft?.customAnswer ?? "";
103151 const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers);
104152 const isLastQuestion =
......@@ -108,7 +156,7 @@ export function derivePendingUserInputProgress(
108156 questionIndex: normalizedQuestionIndex,
109157 activeQuestion,
110158 activeDraft,
111 selectedOptionLabel: activeDraft?.selectedOptionLabel,
159 selectedOptionLabels: normalizeSelectedOptionLabels(activeDraft?.selectedOptionLabels),
112160 customAnswer,
113161 resolvedAnswer,
114162 usingCustomAnswer: customAnswer.trim().length > 0,
apps/web/src/session-logic.test.ts+4
......@@ -197,6 +197,7 @@ describe("derivePendingUserInputs", () => {
197197 description: "Allow workspace writes only",
198198 },
199199 ],
200 multiSelect: true,
200201 },
201202 ],
202203 },
......@@ -233,6 +234,7 @@ describe("derivePendingUserInputs", () => {
233234 description: "Continue execution",
234235 },
235236 ],
237 multiSelect: false,
236238 },
237239 ],
238240 },
......@@ -254,6 +256,7 @@ describe("derivePendingUserInputs", () => {
254256 description: "Allow workspace writes only",
255257 },
256258 ],
259 multiSelect: true,
257260 },
258261 ],
259262 },
......@@ -281,6 +284,7 @@ describe("derivePendingUserInputs", () => {
281284 description: "Allow workspace writes only",
282285 },
283286 ],
287 multiSelect: false,
284288 },
285289 ],
286290 },
apps/web/src/session-logic.ts+1
......@@ -279,6 +279,7 @@ function parseUserInputQuestions(
279279 header: question.header,
280280 question: question.question,
281281 options,
282 multiSelect: question.multiSelect === true,
282283 };
283284 })
284285 .filter((question): question is UserInputQuestion => question !== null);