From 91ef9301c2ce0afb41512e5e3644b1bc3a547790 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 3 Jun 2026 19:49:36 +0800 Subject: [PATCH] Show question prompts one at a time * Show question prompts one at a time * Make question prompt submit action literal --- packages/app/e2e/helpers/questions.ts | 61 ++++ .../e2e/question-prompt-pagination.spec.ts | 73 +++++ .../app/src/components/question-form-card.tsx | 300 +++++++++++++----- .../agent/providers/mock-load-test-agent.ts | 79 ++++- 4 files changed, 435 insertions(+), 78 deletions(-) create mode 100644 packages/app/e2e/helpers/questions.ts create mode 100644 packages/app/e2e/question-prompt-pagination.spec.ts diff --git a/packages/app/e2e/helpers/questions.ts b/packages/app/e2e/helpers/questions.ts new file mode 100644 index 000000000..93f0b4442 --- /dev/null +++ b/packages/app/e2e/helpers/questions.ts @@ -0,0 +1,61 @@ +import { expect, type Page } from "@playwright/test"; + +export async function waitForQuestionPrompt(page: Page, timeout = 30_000): Promise { + await expect(page.getByTestId("question-form-card").first()).toBeVisible({ timeout }); +} + +export async function expectCurrentQuestion( + page: Page, + input: { index: number; total: number; question: string }, +): Promise { + const card = page.getByTestId("question-form-card").first(); + await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question); + await expect( + card.getByRole("button", { name: `Question ${input.index} of ${input.total}` }), + ).toHaveAttribute("aria-selected", "true"); +} + +export async function expectQuestionHidden(page: Page, question: string): Promise { + await expect(page.getByText(question, { exact: true })).toHaveCount(0); +} + +export async function chooseQuestionOption(page: Page, option: string): Promise { + await page + .getByTestId("question-form-card") + .first() + .getByRole("button", { name: option }) + .click(); +} + +export async function expectQuestionOptionSelected(page: Page, option: string): Promise { + await expect( + page.getByTestId("question-form-card").first().getByRole("button", { name: option }), + ).toHaveAttribute("aria-selected", "true"); +} + +export async function openQuestion( + page: Page, + input: { index: number; total: number }, +): Promise { + await page + .getByTestId("question-form-card") + .first() + .getByRole("button", { name: `Question ${input.index} of ${input.total}` }) + .click(); +} + +export async function fillQuestionAnswer( + page: Page, + input: { question: string; answer: string }, +): Promise { + await page + .getByTestId("question-form-card") + .first() + .getByRole("textbox", { name: input.question }) + .fill(input.answer); +} + +export async function submitQuestionAnswers(page: Page): Promise { + await page.getByTestId("question-form-primary-action").click(); + await expect(page.getByTestId("question-form-card")).toHaveCount(0, { timeout: 30_000 }); +} diff --git a/packages/app/e2e/question-prompt-pagination.spec.ts b/packages/app/e2e/question-prompt-pagination.spec.ts new file mode 100644 index 000000000..60f8520f0 --- /dev/null +++ b/packages/app/e2e/question-prompt-pagination.spec.ts @@ -0,0 +1,73 @@ +import { test } from "./fixtures"; +import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { + chooseQuestionOption, + expectCurrentQuestion, + expectQuestionHidden, + expectQuestionOptionSelected, + fillQuestionAnswer, + openQuestion, + submitQuestionAnswers, + waitForQuestionPrompt, +} from "./helpers/questions"; + +const TOTAL_QUESTIONS = 3; +const SURFACE_QUESTION = "Which surface should this apply to?"; +const ROLLOUT_QUESTION = "Which rollout should we use?"; +const SUCCESS_QUESTION = "What success criteria should we use?"; + +test.describe("Question prompt pagination", () => { + test("shows one question at a time with numbered navigation", async ({ page }) => { + test.setTimeout(180_000); + + const session = await seedMockAgentWorkspace({ + repoPrefix: "question-pagination-", + title: "Question pagination e2e", + initialPrompt: "Emit synthetic questions.", + }); + + try { + await openAgentRoute(page, session); + await waitForQuestionPrompt(page, 120_000); + + await expectCurrentQuestion(page, { + index: 1, + total: TOTAL_QUESTIONS, + question: SURFACE_QUESTION, + }); + await expectQuestionHidden(page, ROLLOUT_QUESTION); + await expectQuestionHidden(page, SUCCESS_QUESTION); + + await chooseQuestionOption(page, "App"); + await expectCurrentQuestion(page, { + index: 2, + total: TOTAL_QUESTIONS, + question: ROLLOUT_QUESTION, + }); + + await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS }); + await expectCurrentQuestion(page, { + index: 1, + total: TOTAL_QUESTIONS, + question: SURFACE_QUESTION, + }); + await expectQuestionOptionSelected(page, "App"); + + await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS }); + await chooseQuestionOption(page, "Behind feature flag"); + await expectCurrentQuestion(page, { + index: 3, + total: TOTAL_QUESTIONS, + question: SUCCESS_QUESTION, + }); + + await fillQuestionAnswer(page, { + question: SUCCESS_QUESTION, + answer: "Only one prompt is visible at a time.", + }); + await submitQuestionAnswers(page); + } finally { + await session.cleanup(); + } + }); +}); diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 527ee4b2e..29cfe681f 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -9,7 +9,7 @@ import { } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { Check, CircleHelp, X } from "lucide-react-native"; +import { Check, X } from "lucide-react-native"; import type { PendingPermission } from "@/types/shared"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import { isWeb } from "@/constants/platform"; @@ -82,9 +82,18 @@ function QuestionOptionRow({ () => [styles.optionDescription, { color: theme.colors.foregroundMuted }], [theme.colors.foregroundMuted], ); + const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]); return ( - + {option.label} @@ -102,8 +111,73 @@ function QuestionOptionRow({ ); } +interface QuestionNavButtonProps { + index: number; + total: number; + isActive: boolean; + isResponding: boolean; + onSelect: (index: number) => void; +} + +function QuestionNavButton({ + index, + total, + isActive, + isResponding, + onSelect, +}: QuestionNavButtonProps) { + const { theme } = useUnistyles(); + const accessibilityState = useMemo(() => ({ selected: isActive }), [isActive]); + const handlePress = useCallback(() => { + onSelect(index); + }, [index, onSelect]); + const pressableStyle = useCallback( + ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => { + return [ + styles.questionNavButton, + { + backgroundColor: + isActive || Boolean(hovered) ? theme.colors.surface2 : theme.colors.surface1, + borderColor: isActive ? theme.colors.foregroundMuted : theme.colors.border, + }, + pressed && styles.optionItemPressed, + ]; + }, + [ + isActive, + theme.colors.border, + theme.colors.foregroundMuted, + theme.colors.surface1, + theme.colors.surface2, + ], + ); + const textStyle = useMemo( + () => [ + styles.questionNavText, + { color: isActive ? theme.colors.foreground : theme.colors.foregroundMuted }, + ], + [isActive, theme.colors.foreground, theme.colors.foregroundMuted], + ); + + return ( + + {index + 1} + + ); +} + interface QuestionOtherInputProps { qIndex: number; + accessibilityLabel: string; value: string; placeholder: string; isResponding: boolean; @@ -113,6 +187,7 @@ interface QuestionOtherInputProps { function QuestionOtherInput({ qIndex, + accessibilityLabel, value, placeholder, isResponding, @@ -149,6 +224,7 @@ function QuestionOtherInput({ parseQuestionFormQuestions(permission.request.input), + [permission.request.input], + ); const [selections, setSelections] = useState>>({}); const [otherTexts, setOtherTexts] = useState>({}); const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null); + const [activeQuestionIndex, setActiveQuestionIndex] = useState(0); - const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => { - setSelections((prev) => { - const current = prev[qIndex] ?? new Set(); + const toggleOption = useCallback( + (qIndex: number, optIndex: number, multiSelect: boolean) => { + const current = selections[qIndex] ?? new Set(); const next = new Set(current); if (multiSelect) { if (next.has(optIndex)) { @@ -179,23 +259,27 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi } else { next.add(optIndex); } + } else if (next.has(optIndex)) { + next.clear(); } else { - if (next.has(optIndex)) { - next.clear(); - } else { - next.clear(); - next.add(optIndex); - } + next.clear(); + next.add(optIndex); } - return { ...prev, [qIndex]: next }; - }); - setOtherTexts((prev) => { - if (!prev[qIndex]) return prev; - const next = { ...prev }; - delete next[qIndex]; - return next; - }); - }, []); + + setSelections((prev) => ({ ...prev, [qIndex]: next })); + setOtherTexts((prev) => { + if (!prev[qIndex]) return prev; + const nextTexts = { ...prev }; + delete nextTexts[qIndex]; + return nextTexts; + }); + + if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) { + setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1)); + } + }, + [activeQuestionIndex, questions, selections], + ); const setOtherText = useCallback((qIndex: number, text: string) => { setOtherTexts((prev) => ({ ...prev, [qIndex]: text })); @@ -208,6 +292,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi }, []); const allAnswered = areQuestionsAnswered(questions, selections, otherTexts); + const resolvedActiveQuestionIndex = questions + ? Math.min(activeQuestionIndex, questions.length - 1) + : 0; + const activeQuestion = questions?.[resolvedActiveQuestionIndex]; const handleSubmit = useCallback(() => { if (!questions || !allAnswered || isResponding) return; @@ -248,6 +336,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi }); }, [questions, onRespond, otherTexts, permission.request.input, selections]); + const handleSelectQuestion = useCallback((index: number) => { + setActiveQuestionIndex(index); + }, []); + const dismissButtonStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.actionButton, @@ -262,22 +354,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi const submitDisabled = !allAnswered || isResponding; const submitButtonStyle = useCallback( - ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ + ({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.actionButton, { - backgroundColor: hovered && !submitDisabled ? theme.colors.surface2 : theme.colors.surface1, - borderColor: submitDisabled ? theme.colors.border : theme.colors.borderAccent, + backgroundColor: theme.colors.accent, + borderColor: theme.colors.accent, opacity: submitDisabled ? 0.5 : 1, }, pressed && !submitDisabled ? styles.optionItemPressed : null, ], - [ - submitDisabled, - theme.colors.surface2, - theme.colors.surface1, - theme.colors.border, - theme.colors.borderAccent, - ], + [submitDisabled, theme.colors.accent], ); const containerStyle = useMemo( @@ -294,6 +380,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi () => [styles.questionText, { color: theme.colors.foreground }], [theme.colors.foreground], ); + const questionNavStyle = useMemo( + () => [styles.questionNav, isMobile && styles.questionNavMobile], + [isMobile], + ); const actionsContainerStyle = useMemo( () => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop], [isMobile], @@ -302,9 +392,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi () => [styles.actionText, { color: theme.colors.foregroundMuted }], [theme.colors.foregroundMuted], ); - const submitActionTextColor = allAnswered - ? theme.colors.foreground - : theme.colors.foregroundMuted; + const submitActionTextColor = theme.colors.accentForeground; const submitActionTextStyle = useMemo( () => [styles.actionText, { color: submitActionTextColor }], [submitActionTextColor], @@ -315,52 +403,76 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi } const dismissLabel = resolveDismissLabel(questions); + const selected = selections[resolvedActiveQuestionIndex] ?? new Set(); + const otherText = otherTexts[resolvedActiveQuestionIndex] ?? ""; + const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false; return ( - - {questions.map((q, qIndex) => { - const selected = selections[qIndex] ?? new Set(); - const otherText = otherTexts[qIndex] ?? ""; - const showTextInput = questionShowsTextInput(q); - - return ( - - - {q.question} - - - {q.options.length > 0 ? ( - - {q.options.map((opt, optIndex) => ( - - ))} - - ) : null} - {showTextInput ? ( - + + + + {activeQuestion?.question} + + + + {questions.map((question, qIndex) => { + const isActive = qIndex === resolvedActiveQuestionIndex; + return ( + - ) : null} - - ); - })} + ); + })} + + + + {activeQuestion ? ( + + {activeQuestion.options.length > 0 ? ( + + {activeQuestion.options.map((opt, optIndex) => ( + + ))} + + ) : null} + {showTextInput ? ( + + ) : null} + + ) : null} - + {respondingAction === "dismiss" ? ( ) : ( @@ -371,9 +483,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi )} - + {respondingAction === "submit" ? ( - + ) : ( @@ -396,21 +515,50 @@ const styles = StyleSheet.create((theme) => ({ questionBlock: { gap: theme.spacing[2], }, + questionTopRow: { + flexDirection: "row", + alignItems: "flex-start", + justifyContent: "space-between", + gap: theme.spacing[3], + }, questionHeader: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[1], + flex: 1, }, questionText: { flex: 1, fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, lineHeight: 22, }, optionsWrap: { gap: theme.spacing[1], }, + questionNav: { + flexDirection: "row", + alignItems: "center", + justifyContent: "flex-end", + gap: theme.spacing[1], + }, + questionNavMobile: { + paddingRight: theme.spacing[1], + }, + questionNavButton: { + minWidth: 28, + height: 28, + alignItems: "center", + justifyContent: "center", + borderRadius: 999, + borderWidth: theme.borderWidth[1], + }, + questionNavText: { + fontSize: theme.fontSize.xs, + fontWeight: "700", + }, optionItem: { flexDirection: "row", alignItems: "center", diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 64d34bc34..6f7140930 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -128,6 +128,10 @@ function shouldEmitPlanApprovalPrompt(prompt: AgentPromptInput): boolean { return /emit\s+(?:a\s+)?synthetic\s+plan\s+approval/i.test(promptToText(prompt)); } +function shouldEmitQuestionPrompt(prompt: AgentPromptInput): boolean { + return /emit\s+(?:a\s+)?synthetic\s+questions?/i.test(promptToText(prompt)); +} + function resolveModelProfile(modelId: string | null | undefined): { modelId: string; durationMs: number; @@ -524,6 +528,8 @@ export class MockLoadTestAgentSession implements AgentSession { const stress = parseAgentStreamStressPrompt(prompt); if (shouldEmitPlanApprovalPrompt(prompt)) { this.schedulePlanApprovalTurn(turn); + } else if (shouldEmitQuestionPrompt(prompt)) { + this.scheduleQuestionPromptTurn(turn); } else if (largePayload) { this.scheduleLargePayloadTurn(turn, largePayload); } else if (stress) { @@ -576,9 +582,11 @@ export class MockLoadTestAgentSession implements AgentSession { requestId: string, response: AgentPermissionResponse, ): Promise { - if (!this.pendingPermissions.delete(requestId)) { + const request = this.pendingPermissions.get(requestId); + if (!request) { return undefined; } + this.pendingPermissions.delete(requestId); const turn = this.activeTurn; this.emit({ @@ -590,7 +598,12 @@ export class MockLoadTestAgentSession implements AgentSession { }); if (turn) { - this.finishTurnWithText(turn, "Synthetic plan approval resolved"); + this.finishTurnWithText( + turn, + request.kind === "question" + ? "Synthetic questions resolved" + : "Synthetic plan approval resolved", + ); } return undefined; } @@ -689,6 +702,13 @@ export class MockLoadTestAgentSession implements AgentSession { turn.timer.unref?.(); } + private scheduleQuestionPromptTurn(turn: ActiveTurn): void { + turn.timer = setTimeout(() => { + this.emitQuestionPromptTurn(turn); + }, 0); + turn.timer.unref?.(); + } + private emitPlanApprovalTurn(turn: ActiveTurn): void { if (this.activeTurn !== turn) { return; @@ -741,6 +761,61 @@ export class MockLoadTestAgentSession implements AgentSession { }); } + private emitQuestionPromptTurn(turn: ActiveTurn): void { + if (this.activeTurn !== turn) { + return; + } + + this.clearTurnTimer(turn); + this.emit({ + type: "turn_started", + provider: this.provider, + turnId: turn.turnId, + }); + + const request: AgentPermissionRequest = { + id: `mock-questions-${turn.turnId}`, + provider: this.provider, + name: "MockQuestions", + kind: "question", + title: "Questions", + input: { + questions: [ + { + question: "Which surface should this apply to?", + header: "surface", + options: [{ label: "App" }, { label: "Desktop" }], + multiSelect: false, + }, + { + question: "Which rollout should we use?", + header: "rollout", + options: [{ label: "Immediately" }, { label: "Behind feature flag" }], + multiSelect: false, + }, + { + question: "What success criteria should we use?", + header: "success", + options: [], + multiSelect: false, + placeholder: "Describe success...", + }, + ], + }, + metadata: { + source: "mock_questions", + }, + }; + + this.pendingPermissions.set(request.id, request); + this.emit({ + type: "permission_requested", + provider: this.provider, + request, + turnId: turn.turnId, + }); + } + private emitStressTurn(turn: ActiveTurn, stress: AgentStreamStressRequest): void { if (this.activeTurn !== turn) { return;