Skip to content

Commit 11d456f

Browse files
Support multi-select pending user inputs (#1797)
1 parent a221542 commit 11d456f

9 files changed

Lines changed: 398 additions & 129 deletions

File tree

apps/server/src/provider/Layers/CodexAdapter.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 179 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -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

544-
function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
545+
function 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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
buildPendingUserInputAnswers,
6262
derivePendingUserInputProgress,
6363
setPendingUserInputCustomAnswer,
64+
togglePendingUserInputOptionSelection,
6465
type PendingUserInputDraftAnswer,
6566
} from "../pendingUserInput";
6667
import { 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>

0 commit comments

Comments
 (0)