| author | |
| committer | |
| log | 11d456f6c8404be6617b7299a9db9ad5538ee061 |
| tree | 9fe12742965bacbc4e97752035207b7f094fbbe2 |
| parent | a221542921fd01271f67c6bfa2a0fe7f7ff78dc3 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 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 | 750 | if (events[0]?.type === "user-input.requested") { |
| 750 | 751 | assert.equal(events[0].requestId, "req-user-input-1"); |
| 751 | 752 | assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode"); |
| 753 | assert.equal(events[0].payload.questions[0]?.multiSelect, true); | |
| 752 | 754 | } |
| 753 | 755 | |
| 754 | 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 | 382 | header, |
| 383 | 383 | question: prompt, |
| 384 | 384 | options, |
| 385 | multiSelect: question.multiSelect === true, | |
| 385 | 386 | }; |
| 386 | 387 | }) |
| 387 | 388 | .filter( |
| ... | ... | @@ -392,6 +393,7 @@ function toUserInputQuestions(payload: Record<string, unknown> | undefined) { |
| 392 | 393 | header: string; |
| 393 | 394 | question: string; |
| 394 | 395 | options: Array<{ label: string; description: string }>; |
| 396 | multiSelect: boolean; | |
| 395 | 397 | } => question !== undefined, |
| 396 | 398 | ); |
| 397 | 399 |
apps/web/src/components/ChatView.browser.tsx+179-33| ... | ... | @@ -12,6 +12,7 @@ import { |
| 12 | 12 | type ServerLifecycleWelcomePayload, |
| 13 | 13 | type ThreadId, |
| 14 | 14 | type TurnId, |
| 15 | type UserInputQuestion, | |
| 15 | 16 | WS_METHODS, |
| 16 | 17 | OrchestrationSessionStatus, |
| 17 | 18 | DEFAULT_SERVER_SETTINGS, |
| ... | ... | @@ -541,12 +542,51 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { |
| 541 | 542 | }; |
| 542 | 543 | } |
| 543 | 544 | |
| 544 | function createSnapshotWithPendingUserInput(): OrchestrationReadModel { | |
| 545 | function createSnapshotWithPendingUserInput(options?: { | |
| 546 | questions?: ReadonlyArray<UserInputQuestion>; | |
| 547 | }): OrchestrationReadModel { | |
| 545 | 548 | const snapshot = createSnapshotForTargetUser({ |
| 546 | 549 | targetMessageId: "msg-user-pending-input-target" as MessageId, |
| 547 | 550 | targetText: "question thread", |
| 548 | 551 | }); |
| 549 | 552 | |
| 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 | 590 | return { |
| 551 | 591 | ...snapshot, |
| 552 | 592 | threads: snapshot.threads.map((thread) => |
| ... | ... | @@ -561,38 +601,7 @@ function createSnapshotWithPendingUserInput(): OrchestrationReadModel { |
| 561 | 601 | summary: "User input requested", |
| 562 | 602 | payload: { |
| 563 | 603 | 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, | |
| 596 | 605 | }, |
| 597 | 606 | turnId: null, |
| 598 | 607 | sequence: 1, |
| ... | ... | @@ -2902,6 +2911,143 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2902 | 2911 | } |
| 2903 | 2912 | }); |
| 2904 | 2913 | |
| 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 | 3051 | it("keeps plan follow-up footer actions fused and aligned after a real resize", async () => { |
| 2906 | 3052 | const mounted = await mountChatView({ |
| 2907 | 3053 | viewport: WIDE_FOOTER_VIEWPORT, |
apps/web/src/components/ChatView.tsx+12-6| ... | ... | @@ -61,6 +61,7 @@ import { |
| 61 | 61 | buildPendingUserInputAnswers, |
| 62 | 62 | derivePendingUserInputProgress, |
| 63 | 63 | setPendingUserInputCustomAnswer, |
| 64 | togglePendingUserInputOptionSelection, | |
| 64 | 65 | type PendingUserInputDraftAnswer, |
| 65 | 66 | } from "../pendingUserInput"; |
| 66 | 67 | import { useStore } from "../store"; |
| ... | ... | @@ -3207,19 +3208,24 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3207 | 3208 | [activePendingUserInput], |
| 3208 | 3209 | ); |
| 3209 | 3210 | |
| 3210 | const onSelectActivePendingUserInputOption = useCallback( | |
| 3211 | const onToggleActivePendingUserInputOption = useCallback( | |
| 3211 | 3212 | (questionId: string, optionLabel: string) => { |
| 3212 | 3213 | if (!activePendingUserInput) { |
| 3213 | 3214 | return; |
| 3214 | 3215 | } |
| 3216 | const question = activePendingUserInput.questions.find((entry) => entry.id === questionId); | |
| 3217 | if (!question) { | |
| 3218 | return; | |
| 3219 | } | |
| 3215 | 3220 | setPendingUserInputAnswersByRequestId((existing) => ({ |
| 3216 | 3221 | ...existing, |
| 3217 | 3222 | [activePendingUserInput.requestId]: { |
| 3218 | 3223 | ...existing[activePendingUserInput.requestId], |
| 3219 | [questionId]: { | |
| 3220 | selectedOptionLabel: optionLabel, | |
| 3221 | customAnswer: "", | |
| 3222 | }, | |
| 3224 | [questionId]: togglePendingUserInputOptionSelection( | |
| 3225 | question, | |
| 3226 | existing[activePendingUserInput.requestId]?.[questionId], | |
| 3227 | optionLabel, | |
| 3228 | ), | |
| 3223 | 3229 | }, |
| 3224 | 3230 | })); |
| 3225 | 3231 | promptRef.current = ""; |
| ... | ... | @@ -4063,7 +4069,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 4063 | 4069 | respondingRequestIds={respondingRequestIds} |
| 4064 | 4070 | answers={activePendingDraftAnswers} |
| 4065 | 4071 | questionIndex={activePendingQuestionIndex} |
| 4066 | onSelectOption={onSelectActivePendingUserInputOption} | |
| 4072 | onToggleOption={onToggleActivePendingUserInputOption} | |
| 4067 | 4073 | onAdvance={onAdvanceActivePendingUserInput} |
| 4068 | 4074 | /> |
| 4069 | 4075 | </div> |
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+22-20| ... | ... | @@ -13,7 +13,7 @@ interface PendingUserInputPanelProps { |
| 13 | 13 | respondingRequestIds: ApprovalRequestId[]; |
| 14 | 14 | answers: Record<string, PendingUserInputDraftAnswer>; |
| 15 | 15 | questionIndex: number; |
| 16 | onSelectOption: (questionId: string, optionLabel: string) => void; | |
| 16 | onToggleOption: (questionId: string, optionLabel: string) => void; | |
| 17 | 17 | onAdvance: () => void; |
| 18 | 18 | } |
| 19 | 19 | |
| ... | ... | @@ -22,7 +22,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn |
| 22 | 22 | respondingRequestIds, |
| 23 | 23 | answers, |
| 24 | 24 | questionIndex, |
| 25 | onSelectOption, | |
| 25 | onToggleOption, | |
| 26 | 26 | onAdvance, |
| 27 | 27 | }: PendingUserInputPanelProps) { |
| 28 | 28 | if (pendingUserInputs.length === 0) return null; |
| ... | ... | @@ -36,7 +36,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn |
| 36 | 36 | isResponding={respondingRequestIds.includes(activePrompt.requestId)} |
| 37 | 37 | answers={answers} |
| 38 | 38 | questionIndex={questionIndex} |
| 39 | onSelectOption={onSelectOption} | |
| 39 | onToggleOption={onToggleOption} | |
| 40 | 40 | onAdvance={onAdvance} |
| 41 | 41 | /> |
| 42 | 42 | ); |
| ... | ... | @@ -47,14 +47,14 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 47 | 47 | isResponding, |
| 48 | 48 | answers, |
| 49 | 49 | questionIndex, |
| 50 | onSelectOption, | |
| 50 | onToggleOption, | |
| 51 | 51 | onAdvance, |
| 52 | 52 | }: { |
| 53 | 53 | prompt: PendingUserInput; |
| 54 | 54 | isResponding: boolean; |
| 55 | 55 | answers: Record<string, PendingUserInputDraftAnswer>; |
| 56 | 56 | questionIndex: number; |
| 57 | onSelectOption: (questionId: string, optionLabel: string) => void; | |
| 57 | onToggleOption: (questionId: string, optionLabel: string) => void; | |
| 58 | 58 | onAdvance: () => void; |
| 59 | 59 | }) { |
| 60 | 60 | const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); |
| ... | ... | @@ -70,9 +70,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 70 | 70 | }; |
| 71 | 71 | }, []); |
| 72 | 72 | |
| 73 | const selectOptionAndAutoAdvance = useCallback( | |
| 73 | const handleOptionSelection = useCallback( | |
| 74 | 74 | (questionId: string, optionLabel: string) => { |
| 75 | onSelectOption(questionId, optionLabel); | |
| 75 | onToggleOption(questionId, optionLabel); | |
| 76 | if (activeQuestion?.multiSelect) { | |
| 77 | return; | |
| 78 | } | |
| 76 | 79 | if (autoAdvanceTimerRef.current !== null) { |
| 77 | 80 | window.clearTimeout(autoAdvanceTimerRef.current); |
| 78 | 81 | } |
| ... | ... | @@ -81,13 +84,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 81 | 84 | onAdvance(); |
| 82 | 85 | }, 200); |
| 83 | 86 | }, |
| 84 | [onSelectOption, onAdvance], | |
| 87 | [activeQuestion?.multiSelect, onAdvance, onToggleOption], | |
| 85 | 88 | ); |
| 86 | 89 | |
| 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. | |
| 91 | 93 | useEffect(() => { |
| 92 | 94 | if (!activeQuestion || isResponding) return; |
| 93 | 95 | const handler = (event: globalThis.KeyboardEvent) => { |
| ... | ... | @@ -96,11 +98,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 96 | 98 | if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { |
| 97 | 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 | 101 | if (target instanceof HTMLElement && target.isContentEditable) { |
| 102 | const hasCustomText = progress.customAnswer.length > 0; | |
| 103 | if (hasCustomText) return; | |
| 102 | return; | |
| 104 | 103 | } |
| 105 | 104 | const digit = Number.parseInt(event.key, 10); |
| 106 | 105 | if (Number.isNaN(digit) || digit < 1 || digit > 9) return; |
| ... | ... | @@ -109,11 +108,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 109 | 108 | const option = activeQuestion.options[optionIndex]; |
| 110 | 109 | if (!option) return; |
| 111 | 110 | event.preventDefault(); |
| 112 | selectOptionAndAutoAdvance(activeQuestion.id, option.label); | |
| 111 | handleOptionSelection(activeQuestion.id, option.label); | |
| 113 | 112 | }; |
| 114 | 113 | document.addEventListener("keydown", handler); |
| 115 | 114 | return () => document.removeEventListener("keydown", handler); |
| 116 | }, [activeQuestion, isResponding, selectOptionAndAutoAdvance, progress.customAnswer.length]); | |
| 115 | }, [activeQuestion, handleOptionSelection, isResponding]); | |
| 117 | 116 | |
| 118 | 117 | if (!activeQuestion) { |
| 119 | 118 | return null; |
| ... | ... | @@ -134,16 +133,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 134 | 133 | </div> |
| 135 | 134 | </div> |
| 136 | 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 | 139 | <div className="mt-3 space-y-1"> |
| 138 | 140 | {activeQuestion.options.map((option, index) => { |
| 139 | const isSelected = progress.selectedOptionLabel === option.label; | |
| 141 | const isSelected = progress.selectedOptionLabels.includes(option.label); | |
| 140 | 142 | const shortcutKey = index < 9 ? index + 1 : null; |
| 141 | 143 | return ( |
| 142 | 144 | <button |
| 143 | 145 | key={`${activeQuestion.id}:${option.label}`} |
| 144 | 146 | type="button" |
| 145 | 147 | disabled={isResponding} |
| 146 | onClick={() => selectOptionAndAutoAdvance(activeQuestion.id, option.label)} | |
| 148 | onClick={() => handleOptionSelection(activeQuestion.id, option.label)} | |
| 147 | 149 | className={cn( |
| 148 | 150 | "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-all duration-150", |
| 149 | 151 | isSelected |
apps/web/src/pendingUserInput.test.ts+113-55| ... | ... | @@ -7,57 +7,109 @@ import { |
| 7 | 7 | findFirstUnansweredPendingUserInputQuestionIndex, |
| 8 | 8 | resolvePendingUserInputAnswer, |
| 9 | 9 | setPendingUserInputCustomAnswer, |
| 10 | togglePendingUserInputOptionSelection, | |
| 10 | 11 | } from "./pendingUserInput"; |
| 11 | 12 | |
| 13 | const 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 | ||
| 26 | const 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 | ||
| 12 | 43 | describe("resolvePendingUserInputAnswer", () => { |
| 13 | it("prefers a custom answer over a selected option", () => { | |
| 44 | it("prefers a custom answer over selected options", () => { | |
| 14 | 45 | expect( |
| 15 | resolvePendingUserInputAnswer({ | |
| 16 | selectedOptionLabel: "Keep current envelope", | |
| 46 | resolvePendingUserInputAnswer(singleSelectQuestion, { | |
| 47 | selectedOptionLabels: ["Orchestration-first"], | |
| 17 | 48 | customAnswer: "Keep the existing envelope for one release", |
| 18 | 49 | }), |
| 19 | 50 | ).toBe("Keep the existing envelope for one release"); |
| 20 | 51 | }); |
| 21 | 52 | |
| 22 | it("falls back to the selected option", () => { | |
| 53 | it("falls back to the selected option for single-select questions", () => { | |
| 23 | 54 | expect( |
| 24 | resolvePendingUserInputAnswer({ | |
| 25 | selectedOptionLabel: "Scaffold only", | |
| 55 | resolvePendingUserInputAnswer(singleSelectQuestion, { | |
| 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 | }); |
| 29 | 68 | |
| 30 | 69 | it("clears the preset selection when a custom answer is entered", () => { |
| 31 | 70 | expect( |
| 32 | 71 | setPendingUserInputCustomAnswer( |
| 33 | 72 | { |
| 34 | selectedOptionLabel: "Preserve existing tags", | |
| 73 | selectedOptionLabels: ["Server", "Web"], | |
| 35 | 74 | }, |
| 36 | 75 | "doesn't matter", |
| 37 | 76 | ), |
| 38 | 77 | ).toEqual({ |
| 39 | selectedOptionLabel: undefined, | |
| 40 | 78 | customAnswer: "doesn't matter", |
| 41 | 79 | }); |
| 42 | 80 | }); |
| 43 | 81 | }); |
| 44 | 82 | |
| 83 | describe("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 | ||
| 45 | 107 | describe("buildPendingUserInputAnswers", () => { |
| 46 | 108 | it("returns a canonical answer map for complete prompts", () => { |
| 47 | 109 | expect( |
| 48 | 110 | buildPendingUserInputAnswers( |
| 49 | 111 | [ |
| 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, | |
| 61 | 113 | { |
| 62 | 114 | id: "compat", |
| 63 | 115 | header: "Compat", |
| ... | ... | @@ -68,11 +120,12 @@ describe("buildPendingUserInputAnswers", () => { |
| 68 | 120 | description: "Preserve current wire format", |
| 69 | 121 | }, |
| 70 | 122 | ], |
| 123 | multiSelect: false, | |
| 71 | 124 | }, |
| 72 | 125 | ], |
| 73 | 126 | { |
| 74 | 127 | scope: { |
| 75 | selectedOptionLabel: "Orchestration-first", | |
| 128 | selectedOptionLabels: ["Orchestration-first"], | |
| 76 | 129 | }, |
| 77 | 130 | compat: { |
| 78 | 131 | customAnswer: "Keep the current envelope for one release window", |
| ... | ... | @@ -85,41 +138,26 @@ describe("buildPendingUserInputAnswers", () => { |
| 85 | 138 | }); |
| 86 | 139 | }); |
| 87 | 140 | |
| 88 | it("returns null when any question is unanswered", () => { | |
| 141 | it("returns arrays for answered multi-select prompts", () => { | |
| 89 | 142 | 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(); | |
| 107 | 155 | }); |
| 108 | 156 | }); |
| 109 | 157 | |
| 110 | 158 | describe("pending user input question progress", () => { |
| 111 | 159 | 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, | |
| 123 | 161 | { |
| 124 | 162 | id: "compat", |
| 125 | 163 | header: "Compat", |
| ... | ... | @@ -130,6 +168,7 @@ describe("pending user input question progress", () => { |
| 130 | 168 | description: "Preserve current wire format", |
| 131 | 169 | }, |
| 132 | 170 | ], |
| 171 | multiSelect: false, | |
| 133 | 172 | }, |
| 134 | 173 | ] as const; |
| 135 | 174 | |
| ... | ... | @@ -137,7 +176,7 @@ describe("pending user input question progress", () => { |
| 137 | 176 | expect( |
| 138 | 177 | countAnsweredPendingUserInputQuestions(questions, { |
| 139 | 178 | scope: { |
| 140 | selectedOptionLabel: "Orchestration-first", | |
| 179 | selectedOptionLabels: ["Orchestration-first"], | |
| 141 | 180 | }, |
| 142 | 181 | }), |
| 143 | 182 | ).toBe(1); |
| ... | ... | @@ -147,7 +186,7 @@ describe("pending user input question progress", () => { |
| 147 | 186 | expect( |
| 148 | 187 | findFirstUnansweredPendingUserInputQuestionIndex(questions, { |
| 149 | 188 | scope: { |
| 150 | selectedOptionLabel: "Orchestration-first", | |
| 189 | selectedOptionLabels: ["Orchestration-first"], | |
| 151 | 190 | }, |
| 152 | 191 | }), |
| 153 | 192 | ).toBe(1); |
| ... | ... | @@ -157,7 +196,7 @@ describe("pending user input question progress", () => { |
| 157 | 196 | expect( |
| 158 | 197 | findFirstUnansweredPendingUserInputQuestionIndex(questions, { |
| 159 | 198 | scope: { |
| 160 | selectedOptionLabel: "Orchestration-first", | |
| 199 | selectedOptionLabels: ["Orchestration-first"], | |
| 161 | 200 | }, |
| 162 | 201 | compat: { |
| 163 | 202 | customAnswer: "Keep it for one release window", |
| ... | ... | @@ -172,7 +211,7 @@ describe("pending user input question progress", () => { |
| 172 | 211 | questions, |
| 173 | 212 | { |
| 174 | 213 | scope: { |
| 175 | selectedOptionLabel: "Orchestration-first", | |
| 214 | selectedOptionLabels: ["Orchestration-first"], | |
| 176 | 215 | }, |
| 177 | 216 | }, |
| 178 | 217 | 0, |
| ... | ... | @@ -180,7 +219,7 @@ describe("pending user input question progress", () => { |
| 180 | 219 | ).toMatchObject({ |
| 181 | 220 | questionIndex: 0, |
| 182 | 221 | activeQuestion: questions[0], |
| 183 | selectedOptionLabel: "Orchestration-first", | |
| 222 | selectedOptionLabels: ["Orchestration-first"], | |
| 184 | 223 | customAnswer: "", |
| 185 | 224 | resolvedAnswer: "Orchestration-first", |
| 186 | 225 | answeredQuestionCount: 1, |
| ... | ... | @@ -189,4 +228,23 @@ describe("pending user input question progress", () => { |
| 189 | 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 | 1 | import type { UserInputQuestion } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | 3 | export interface PendingUserInputDraftAnswer { |
| 4 | selectedOptionLabel?: string; | |
| 4 | selectedOptionLabels?: string[]; | |
| 5 | 5 | customAnswer?: string; |
| 6 | 6 | } |
| 7 | 7 | |
| ... | ... | @@ -9,9 +9,9 @@ export interface PendingUserInputProgress { |
| 9 | 9 | questionIndex: number; |
| 10 | 10 | activeQuestion: UserInputQuestion | null; |
| 11 | 11 | activeDraft: PendingUserInputDraftAnswer | undefined; |
| 12 | selectedOptionLabel: string | undefined; | |
| 12 | selectedOptionLabels: string[]; | |
| 13 | 13 | customAnswer: string; |
| 14 | resolvedAnswer: string | null; | |
| 14 | resolvedAnswer: string | string[] | null; | |
| 15 | 15 | usingCustomAnswer: boolean; |
| 16 | 16 | answeredQuestionCount: number; |
| 17 | 17 | isLastQuestion: boolean; |
| ... | ... | @@ -28,38 +28,84 @@ function normalizeDraftAnswer(value: string | undefined): string | null { |
| 28 | 28 | return trimmed.length > 0 ? trimmed : null; |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | function 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 | ||
| 31 | 44 | export function resolvePendingUserInputAnswer( |
| 45 | question: UserInputQuestion, | |
| 32 | 46 | draft: PendingUserInputDraftAnswer | undefined, |
| 33 | ): string | null { | |
| 47 | ): string | string[] | null { | |
| 34 | 48 | const customAnswer = normalizeDraftAnswer(draft?.customAnswer); |
| 35 | 49 | if (customAnswer) { |
| 36 | 50 | return customAnswer; |
| 37 | 51 | } |
| 38 | 52 | |
| 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 | } |
| 41 | 60 | |
| 42 | 61 | export function setPendingUserInputCustomAnswer( |
| 43 | 62 | draft: PendingUserInputDraftAnswer | undefined, |
| 44 | 63 | customAnswer: string, |
| 45 | 64 | ): 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); | |
| 48 | 69 | |
| 49 | 70 | return { |
| 50 | 71 | customAnswer, |
| 51 | ...(selectedOptionLabel ? { selectedOptionLabel } : {}), | |
| 72 | ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}), | |
| 73 | }; | |
| 74 | } | |
| 75 | ||
| 76 | export 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 | } |
| 54 | 100 | |
| 55 | 101 | export function buildPendingUserInputAnswers( |
| 56 | 102 | questions: ReadonlyArray<UserInputQuestion>, |
| 57 | 103 | 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[]> = {}; | |
| 60 | 106 | |
| 61 | 107 | for (const question of questions) { |
| 62 | const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); | |
| 108 | const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); | |
| 63 | 109 | if (!answer) { |
| 64 | 110 | return null; |
| 65 | 111 | } |
| ... | ... | @@ -74,7 +120,7 @@ export function countAnsweredPendingUserInputQuestions( |
| 74 | 120 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, |
| 75 | 121 | ): number { |
| 76 | 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 | 124 | }, 0); |
| 79 | 125 | } |
| 80 | 126 | |
| ... | ... | @@ -83,7 +129,7 @@ export function findFirstUnansweredPendingUserInputQuestionIndex( |
| 83 | 129 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, |
| 84 | 130 | ): number { |
| 85 | 131 | const unansweredIndex = questions.findIndex( |
| 86 | (question) => !resolvePendingUserInputAnswer(draftAnswers[question.id]), | |
| 132 | (question) => !resolvePendingUserInputAnswer(question, draftAnswers[question.id]), | |
| 87 | 133 | ); |
| 88 | 134 | |
| 89 | 135 | return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex; |
| ... | ... | @@ -98,7 +144,9 @@ export function derivePendingUserInputProgress( |
| 98 | 144 | questions.length === 0 ? 0 : Math.max(0, Math.min(questionIndex, questions.length - 1)); |
| 99 | 145 | const activeQuestion = questions[normalizedQuestionIndex] ?? null; |
| 100 | 146 | const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined; |
| 101 | const resolvedAnswer = resolvePendingUserInputAnswer(activeDraft); | |
| 147 | const resolvedAnswer = activeQuestion | |
| 148 | ? resolvePendingUserInputAnswer(activeQuestion, activeDraft) | |
| 149 | : null; | |
| 102 | 150 | const customAnswer = activeDraft?.customAnswer ?? ""; |
| 103 | 151 | const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers); |
| 104 | 152 | const isLastQuestion = |
| ... | ... | @@ -108,7 +156,7 @@ export function derivePendingUserInputProgress( |
| 108 | 156 | questionIndex: normalizedQuestionIndex, |
| 109 | 157 | activeQuestion, |
| 110 | 158 | activeDraft, |
| 111 | selectedOptionLabel: activeDraft?.selectedOptionLabel, | |
| 159 | selectedOptionLabels: normalizeSelectedOptionLabels(activeDraft?.selectedOptionLabels), | |
| 112 | 160 | customAnswer, |
| 113 | 161 | resolvedAnswer, |
| 114 | 162 | usingCustomAnswer: customAnswer.trim().length > 0, |
apps/web/src/session-logic.test.ts+4| ... | ... | @@ -197,6 +197,7 @@ describe("derivePendingUserInputs", () => { |
| 197 | 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 | 234 | description: "Continue execution", |
| 234 | 235 | }, |
| 235 | 236 | ], |
| 237 | multiSelect: false, | |
| 236 | 238 | }, |
| 237 | 239 | ], |
| 238 | 240 | }, |
| ... | ... | @@ -254,6 +256,7 @@ describe("derivePendingUserInputs", () => { |
| 254 | 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 | 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 | 279 | header: question.header, |
| 280 | 280 | question: question.question, |
| 281 | 281 | options, |
| 282 | multiSelect: question.multiSelect === true, | |
| 282 | 283 | }; |
| 283 | 284 | }) |
| 284 | 285 | .filter((question): question is UserInputQuestion => question !== null); |