| 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,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 | } |
| 753 | 755 | ||
| 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 | ); |
| 397 | 399 |
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 | } |
| 543 | 544 | ||
| 544 | function createSnapshotWithPendingUserInput(): OrchestrationReadModel { | 545 | function 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 | }); |
| 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 | 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 | }); |
| 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 | 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"; |
| 66 | import { useStore } from "../store"; | 67 | import { 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 | ); |
| 3209 | 3210 | ||
| 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 | } |
| 19 | 19 | ||
| ... | @@ -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 | }, []); |
| 72 | 72 | ||
| 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 | ); |
| 86 | 89 | ||
| 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 composer | 91 | // 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 digit | 92 | // 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]); |
| 117 | 116 | ||
| 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 | <button | 144 | <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 | isSelected | 151 | 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"; |
| 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 | describe("resolvePendingUserInputAnswer", () => { | 43 | describe("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 | }); |
| 21 | 52 | ||
| 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 | }); |
| 29 | 68 | ||
| 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 | }); |
| 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 | describe("buildPendingUserInputAnswers", () => { | 107 | describe("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 | }); |
| 87 | 140 | ||
| 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 | }); |
| 109 | 157 | ||
| 110 | describe("pending user input question progress", () => { | 158 | describe("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; |
| 135 | 174 | ||
| ... | @@ -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 @@ |
| 1 | import type { UserInputQuestion } from "@t3tools/contracts"; | 1 | import type { UserInputQuestion } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | export interface PendingUserInputDraftAnswer { | 3 | export interface PendingUserInputDraftAnswer { |
| 4 | selectedOptionLabel?: string; | 4 | selectedOptionLabels?: string[]; |
| 5 | customAnswer?: string; | 5 | customAnswer?: string; |
| 6 | } | 6 | } |
| 7 | 7 | ||
| ... | @@ -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 | } |
| 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 | export function resolvePendingUserInputAnswer( | 44 | export 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 | } |
| 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 | export function setPendingUserInputCustomAnswer( | 61 | export 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); | ||
| 48 | 69 | ||
| 49 | return { | 70 | return { |
| 50 | customAnswer, | 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 | export function buildPendingUserInputAnswers( | 101 | export 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[]> = {}; |
| 60 | 106 | ||
| 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 | } |
| 80 | 126 | ||
| ... | @@ -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 | ); |
| 88 | 134 | ||
| 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); |