Show question prompts one at a time

* Show question prompts one at a time

* Make question prompt submit action literal
This commit is contained in:
Mohamed Boudra
2026-06-03 19:49:36 +08:00
committed by GitHub
parent e9a746d4ea
commit 91ef9301c2
4 changed files with 435 additions and 78 deletions
+61
View File
@@ -0,0 +1,61 @@
import { expect, type Page } from "@playwright/test";
export async function waitForQuestionPrompt(page: Page, timeout = 30_000): Promise<void> {
await expect(page.getByTestId("question-form-card").first()).toBeVisible({ timeout });
}
export async function expectCurrentQuestion(
page: Page,
input: { index: number; total: number; question: string },
): Promise<void> {
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<void> {
await expect(page.getByText(question, { exact: true })).toHaveCount(0);
}
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
await page
.getByTestId("question-form-card")
.first()
.getByRole("button", { name: option })
.click();
}
export async function expectQuestionOptionSelected(page: Page, option: string): Promise<void> {
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<void> {
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<void> {
await page
.getByTestId("question-form-card")
.first()
.getByRole("textbox", { name: input.question })
.fill(input.answer);
}
export async function submitQuestionAnswers(page: Page): Promise<void> {
await page.getByTestId("question-form-primary-action").click();
await expect(page.getByTestId("question-form-card")).toHaveCount(0, { timeout: 30_000 });
}
@@ -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();
}
});
});
@@ -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 (
<Pressable style={pressableStyle} onPress={handlePress} disabled={isResponding}>
<Pressable
style={pressableStyle}
onPress={handlePress}
disabled={isResponding}
accessibilityRole="button"
accessibilityLabel={option.label}
accessibilityState={accessibilityState}
aria-selected={isSelected}
>
<View style={styles.optionItemContent}>
<View style={styles.optionTextBlock}>
<Text style={optionLabelStyle}>{option.label}</Text>
@@ -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 (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Question ${index + 1} of ${total}`}
accessibilityState={accessibilityState}
aria-selected={isActive}
testID={`question-form-question-nav-${index + 1}`}
style={pressableStyle}
onPress={handlePress}
disabled={isResponding}
>
<Text style={textStyle}>{index + 1}</Text>
</Pressable>
);
}
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({
<TextInput
// @ts-expect-error - outlineStyle is web-only
style={otherInputStyle}
accessibilityLabel={accessibilityLabel}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
@@ -163,15 +239,19 @@ function QuestionOtherInput({
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const questions = parseQuestionFormQuestions(permission.request.input);
const questions = useMemo(
() => parseQuestionFormQuestions(permission.request.input),
[permission.request.input],
);
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
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<number>();
const toggleOption = useCallback(
(qIndex: number, optIndex: number, multiSelect: boolean) => {
const current = selections[qIndex] ?? new Set<number>();
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<number>();
const otherText = otherTexts[resolvedActiveQuestionIndex] ?? "";
const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false;
return (
<View style={containerStyle}>
{questions.map((q, qIndex) => {
const selected = selections[qIndex] ?? new Set<number>();
const otherText = otherTexts[qIndex] ?? "";
const showTextInput = questionShowsTextInput(q);
return (
<View key={q.question} style={styles.questionBlock}>
<View style={styles.questionHeader}>
<Text style={questionTextStyle}>{q.question}</Text>
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
</View>
{q.options.length > 0 ? (
<View style={styles.optionsWrap}>
{q.options.map((opt, optIndex) => (
<QuestionOptionRow
key={opt.label}
qIndex={qIndex}
optIndex={optIndex}
option={opt}
isSelected={selected.has(optIndex)}
multiSelect={q.multiSelect}
isResponding={isResponding}
onToggle={toggleOption}
/>
))}
</View>
) : null}
{showTextInput ? (
<QuestionOtherInput
qIndex={qIndex}
value={otherText}
placeholder={getQuestionInputPlaceholder(q)}
<View style={containerStyle} testID="question-form-card">
<View style={styles.questionTopRow}>
<View style={styles.questionHeader}>
<Text testID="question-form-current-question" style={questionTextStyle}>
{activeQuestion?.question}
</Text>
</View>
<View style={questionNavStyle} testID="question-form-question-nav">
{questions.map((question, qIndex) => {
const isActive = qIndex === resolvedActiveQuestionIndex;
return (
<QuestionNavButton
key={question.header}
index={qIndex}
total={questions.length}
isActive={isActive}
isResponding={isResponding}
onChange={setOtherText}
onSubmit={handleSubmit}
onSelect={handleSelectQuestion}
/>
) : null}
</View>
);
})}
);
})}
</View>
</View>
{activeQuestion ? (
<View key={activeQuestion.question} style={styles.questionBlock}>
{activeQuestion.options.length > 0 ? (
<View style={styles.optionsWrap}>
{activeQuestion.options.map((opt, optIndex) => (
<QuestionOptionRow
key={opt.label}
qIndex={resolvedActiveQuestionIndex}
optIndex={optIndex}
option={opt}
isSelected={selected.has(optIndex)}
multiSelect={activeQuestion.multiSelect}
isResponding={isResponding}
onToggle={toggleOption}
/>
))}
</View>
) : null}
{showTextInput ? (
<QuestionOtherInput
qIndex={resolvedActiveQuestionIndex}
accessibilityLabel={activeQuestion.question}
value={otherText}
placeholder={getQuestionInputPlaceholder(activeQuestion)}
isResponding={isResponding}
onChange={setOtherText}
onSubmit={handleSubmit}
/>
) : null}
</View>
) : null}
<View style={actionsContainerStyle}>
<Pressable style={dismissButtonStyle} onPress={handleDeny} disabled={isResponding}>
<Pressable
style={dismissButtonStyle}
onPress={handleDeny}
disabled={isResponding}
accessibilityRole="button"
accessibilityLabel={dismissLabel}
testID="question-form-dismiss"
>
{respondingAction === "dismiss" ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
@@ -371,9 +483,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
)}
</Pressable>
<Pressable style={submitButtonStyle} onPress={handleSubmit} disabled={submitDisabled}>
<Pressable
style={submitButtonStyle}
onPress={handleSubmit}
disabled={submitDisabled}
accessibilityRole="button"
accessibilityLabel="Submit"
testID="question-form-primary-action"
>
{respondingAction === "submit" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
<ActivityIndicator size="small" color={theme.colors.accentForeground} />
) : (
<View style={styles.actionContent}>
<Check size={14} color={submitActionTextColor} />
@@ -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",
@@ -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<AgentPermissionResult | void> {
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;