Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
description: "Allow workspace writes only",
},
],
multiSelect: true,
},
],
},
Expand All @@ -749,6 +750,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
if (events[0]?.type === "user-input.requested") {
assert.equal(events[0].requestId, "req-user-input-1");
assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode");
assert.equal(events[0].payload.questions[0]?.multiSelect, true);
}

assert.equal(events[1]?.type, "user-input.resolved");
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ function toUserInputQuestions(payload: Record<string, unknown> | undefined) {
header,
question: prompt,
options,
multiSelect: question.multiSelect === true,
};
})
.filter(
Expand All @@ -392,6 +393,7 @@ function toUserInputQuestions(payload: Record<string, unknown> | undefined) {
header: string;
question: string;
options: Array<{ label: string; description: string }>;
multiSelect: boolean;
} => question !== undefined,
);

Expand Down
212 changes: 179 additions & 33 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type ServerLifecycleWelcomePayload,
type ThreadId,
type TurnId,
type UserInputQuestion,
WS_METHODS,
OrchestrationSessionStatus,
DEFAULT_SERVER_SETTINGS,
Expand Down Expand Up @@ -541,12 +542,51 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
};
}

function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
function createSnapshotWithPendingUserInput(options?: {
questions?: ReadonlyArray<UserInputQuestion>;
}): OrchestrationReadModel {
const snapshot = createSnapshotForTargetUser({
targetMessageId: "msg-user-pending-input-target" as MessageId,
targetText: "question thread",
});

const questions =
options?.questions ??
([
{
id: "scope",
header: "Scope",
question: "What should this change cover?",
options: [
{
label: "Tight",
description: "Touch only the footer layout logic.",
},
{
label: "Broad",
description: "Also adjust the related composer controls.",
},
],
multiSelect: false,
},
{
id: "risk",
header: "Risk",
question: "How aggressive should the imaginary plan be?",
options: [
{
label: "Conservative",
description: "Favor reliability and low-risk changes.",
},
{
label: "Balanced",
description: "Mix quick wins with one structural improvement.",
},
],
multiSelect: false,
},
] satisfies ReadonlyArray<UserInputQuestion>);

return {
...snapshot,
threads: snapshot.threads.map((thread) =>
Expand All @@ -561,38 +601,7 @@ function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
summary: "User input requested",
payload: {
requestId: "req-browser-user-input",
questions: [
{
id: "scope",
header: "Scope",
question: "What should this change cover?",
options: [
{
label: "Tight",
description: "Touch only the footer layout logic.",
},
{
label: "Broad",
description: "Also adjust the related composer controls.",
},
],
},
{
id: "risk",
header: "Risk",
question: "How aggressive should the imaginary plan be?",
options: [
{
label: "Conservative",
description: "Favor reliability and low-risk changes.",
},
{
label: "Balanced",
description: "Mix quick wins with one structural improvement.",
},
],
},
],
questions,
},
turnId: null,
sequence: 1,
Expand Down Expand Up @@ -2902,6 +2911,143 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});

it("does not trigger numeric option shortcuts while the composer is focused", async () => {
const mounted = await mountChatView({
viewport: WIDE_FOOTER_VIEWPORT,
snapshot: createSnapshotWithPendingUserInput(),
});

try {
const composerEditor = await waitForComposerEditor();
composerEditor.focus();

const event = new KeyboardEvent("keydown", {
key: "2",
bubbles: true,
cancelable: true,
});
composerEditor.dispatchEvent(event);
await waitForLayout();

expect(event.defaultPrevented).toBe(false);
expect(document.body.textContent).toContain("What should this change cover?");
expect(document.body.textContent).not.toContain(
"How aggressive should the imaginary plan be?",
);
await waitForButtonByText("Next question");
} finally {
await mounted.cleanup();
}
});

it("submits multi-select questionnaire answers as arrays", async () => {
const mounted = await mountChatView({
viewport: WIDE_FOOTER_VIEWPORT,
snapshot: createSnapshotWithPendingUserInput({
questions: [
{
id: "scope",
header: "Scope",
question: "Which areas should this change cover?",
options: [
{
label: "Server",
description: "Touch server orchestration.",
},
{
label: "Web",
description: "Touch the browser UI.",
},
],
multiSelect: true,
},
{
id: "risk",
header: "Risk",
question: "How aggressive should the imaginary plan be?",
options: [
{
label: "Balanced",
description: "Mix quick wins with one structural improvement.",
},
],
multiSelect: false,
},
],
}),
resolveRpc: (body) => {
if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
return {
sequence: fixture.snapshot.snapshotSequence + 1,
};
}
return undefined;
},
});

try {
const serverOption = await waitForButtonContainingText("Server");
serverOption.click();
await waitForLayout();

expect(document.body.textContent).toContain("Which areas should this change cover?");

const webOption = await waitForButtonContainingText("Web");
webOption.click();
await waitForLayout();

expect(document.body.textContent).toContain("Which areas should this change cover?");

const nextButton = await waitForButtonByText("Next question");
expect(nextButton.disabled).toBe(false);
nextButton.click();

await vi.waitFor(
() => {
expect(document.body.textContent).toContain(
"How aggressive should the imaginary plan be?",
);
},
{ timeout: 8_000, interval: 16 },
);

const balancedOption = await waitForButtonContainingText("Balanced");
balancedOption.click();

const submitButton = await waitForButtonByText("Submit answers");
expect(submitButton.disabled).toBe(false);
submitButton.click();

await vi.waitFor(
() => {
const dispatchRequest = wsRequests.find(
(request) =>
request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand &&
request.type === "thread.user-input.respond",
) as
| {
_tag: string;
type?: string;
answers?: Record<string, unknown>;
}
| undefined;

expect(dispatchRequest).toMatchObject({
_tag: ORCHESTRATION_WS_METHODS.dispatchCommand,
type: "thread.user-input.respond",
answers: {
scope: ["Server", "Web"],
risk: "Balanced",
},
});
},
{ timeout: 8_000, interval: 16 },
);
} finally {
await mounted.cleanup();
}
});

it("keeps plan follow-up footer actions fused and aligned after a real resize", async () => {
const mounted = await mountChatView({
viewport: WIDE_FOOTER_VIEWPORT,
Expand Down
18 changes: 12 additions & 6 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
buildPendingUserInputAnswers,
derivePendingUserInputProgress,
setPendingUserInputCustomAnswer,
togglePendingUserInputOptionSelection,
type PendingUserInputDraftAnswer,
} from "../pendingUserInput";
import { useStore } from "../store";
Expand Down Expand Up @@ -3207,19 +3208,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
[activePendingUserInput],
);

const onSelectActivePendingUserInputOption = useCallback(
const onToggleActivePendingUserInputOption = useCallback(
(questionId: string, optionLabel: string) => {
if (!activePendingUserInput) {
return;
}
const question = activePendingUserInput.questions.find((entry) => entry.id === questionId);
if (!question) {
return;
}
setPendingUserInputAnswersByRequestId((existing) => ({
...existing,
[activePendingUserInput.requestId]: {
...existing[activePendingUserInput.requestId],
[questionId]: {
selectedOptionLabel: optionLabel,
customAnswer: "",
},
[questionId]: togglePendingUserInputOptionSelection(
question,
existing[activePendingUserInput.requestId]?.[questionId],
optionLabel,
),
},
}));
promptRef.current = "";
Expand Down Expand Up @@ -4063,7 +4069,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
respondingRequestIds={respondingRequestIds}
answers={activePendingDraftAnswers}
questionIndex={activePendingQuestionIndex}
onSelectOption={onSelectActivePendingUserInputOption}
onToggleOption={onToggleActivePendingUserInputOption}
onAdvance={onAdvanceActivePendingUserInput}
/>
</div>
Expand Down
Loading
Loading