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:
@@ -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";
|
} from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
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 { PendingPermission } from "@/types/shared";
|
||||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
@@ -82,9 +82,18 @@ function QuestionOptionRow({
|
|||||||
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
|
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
|
||||||
[theme.colors.foregroundMuted],
|
[theme.colors.foregroundMuted],
|
||||||
);
|
);
|
||||||
|
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
||||||
|
|
||||||
return (
|
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.optionItemContent}>
|
||||||
<View style={styles.optionTextBlock}>
|
<View style={styles.optionTextBlock}>
|
||||||
<Text style={optionLabelStyle}>{option.label}</Text>
|
<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 {
|
interface QuestionOtherInputProps {
|
||||||
qIndex: number;
|
qIndex: number;
|
||||||
|
accessibilityLabel: string;
|
||||||
value: string;
|
value: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
isResponding: boolean;
|
isResponding: boolean;
|
||||||
@@ -113,6 +187,7 @@ interface QuestionOtherInputProps {
|
|||||||
|
|
||||||
function QuestionOtherInput({
|
function QuestionOtherInput({
|
||||||
qIndex,
|
qIndex,
|
||||||
|
accessibilityLabel,
|
||||||
value,
|
value,
|
||||||
placeholder,
|
placeholder,
|
||||||
isResponding,
|
isResponding,
|
||||||
@@ -149,6 +224,7 @@ function QuestionOtherInput({
|
|||||||
<TextInput
|
<TextInput
|
||||||
// @ts-expect-error - outlineStyle is web-only
|
// @ts-expect-error - outlineStyle is web-only
|
||||||
style={otherInputStyle}
|
style={otherInputStyle}
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -163,15 +239,19 @@ function QuestionOtherInput({
|
|||||||
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
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 [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
||||||
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
|
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
|
||||||
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
|
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
|
||||||
|
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
|
||||||
|
|
||||||
const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => {
|
const toggleOption = useCallback(
|
||||||
setSelections((prev) => {
|
(qIndex: number, optIndex: number, multiSelect: boolean) => {
|
||||||
const current = prev[qIndex] ?? new Set<number>();
|
const current = selections[qIndex] ?? new Set<number>();
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
if (multiSelect) {
|
if (multiSelect) {
|
||||||
if (next.has(optIndex)) {
|
if (next.has(optIndex)) {
|
||||||
@@ -179,23 +259,27 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
} else {
|
} else {
|
||||||
next.add(optIndex);
|
next.add(optIndex);
|
||||||
}
|
}
|
||||||
|
} else if (next.has(optIndex)) {
|
||||||
|
next.clear();
|
||||||
} else {
|
} else {
|
||||||
if (next.has(optIndex)) {
|
next.clear();
|
||||||
next.clear();
|
next.add(optIndex);
|
||||||
} else {
|
|
||||||
next.clear();
|
|
||||||
next.add(optIndex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { ...prev, [qIndex]: next };
|
|
||||||
});
|
setSelections((prev) => ({ ...prev, [qIndex]: next }));
|
||||||
setOtherTexts((prev) => {
|
setOtherTexts((prev) => {
|
||||||
if (!prev[qIndex]) return prev;
|
if (!prev[qIndex]) return prev;
|
||||||
const next = { ...prev };
|
const nextTexts = { ...prev };
|
||||||
delete next[qIndex];
|
delete nextTexts[qIndex];
|
||||||
return next;
|
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) => {
|
const setOtherText = useCallback((qIndex: number, text: string) => {
|
||||||
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
|
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
|
||||||
@@ -208,6 +292,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
|
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
|
||||||
|
const resolvedActiveQuestionIndex = questions
|
||||||
|
? Math.min(activeQuestionIndex, questions.length - 1)
|
||||||
|
: 0;
|
||||||
|
const activeQuestion = questions?.[resolvedActiveQuestionIndex];
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
if (!questions || !allAnswered || isResponding) return;
|
if (!questions || !allAnswered || isResponding) return;
|
||||||
@@ -248,6 +336,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
});
|
});
|
||||||
}, [questions, onRespond, otherTexts, permission.request.input, selections]);
|
}, [questions, onRespond, otherTexts, permission.request.input, selections]);
|
||||||
|
|
||||||
|
const handleSelectQuestion = useCallback((index: number) => {
|
||||||
|
setActiveQuestionIndex(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const dismissButtonStyle = useCallback(
|
const dismissButtonStyle = useCallback(
|
||||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
styles.actionButton,
|
styles.actionButton,
|
||||||
@@ -262,22 +354,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
|
|
||||||
const submitDisabled = !allAnswered || isResponding;
|
const submitDisabled = !allAnswered || isResponding;
|
||||||
const submitButtonStyle = useCallback(
|
const submitButtonStyle = useCallback(
|
||||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
styles.actionButton,
|
styles.actionButton,
|
||||||
{
|
{
|
||||||
backgroundColor: hovered && !submitDisabled ? theme.colors.surface2 : theme.colors.surface1,
|
backgroundColor: theme.colors.accent,
|
||||||
borderColor: submitDisabled ? theme.colors.border : theme.colors.borderAccent,
|
borderColor: theme.colors.accent,
|
||||||
opacity: submitDisabled ? 0.5 : 1,
|
opacity: submitDisabled ? 0.5 : 1,
|
||||||
},
|
},
|
||||||
pressed && !submitDisabled ? styles.optionItemPressed : null,
|
pressed && !submitDisabled ? styles.optionItemPressed : null,
|
||||||
],
|
],
|
||||||
[
|
[submitDisabled, theme.colors.accent],
|
||||||
submitDisabled,
|
|
||||||
theme.colors.surface2,
|
|
||||||
theme.colors.surface1,
|
|
||||||
theme.colors.border,
|
|
||||||
theme.colors.borderAccent,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
@@ -294,6 +380,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
() => [styles.questionText, { color: theme.colors.foreground }],
|
() => [styles.questionText, { color: theme.colors.foreground }],
|
||||||
[theme.colors.foreground],
|
[theme.colors.foreground],
|
||||||
);
|
);
|
||||||
|
const questionNavStyle = useMemo(
|
||||||
|
() => [styles.questionNav, isMobile && styles.questionNavMobile],
|
||||||
|
[isMobile],
|
||||||
|
);
|
||||||
const actionsContainerStyle = useMemo(
|
const actionsContainerStyle = useMemo(
|
||||||
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
|
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
|
||||||
[isMobile],
|
[isMobile],
|
||||||
@@ -302,9 +392,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
() => [styles.actionText, { color: theme.colors.foregroundMuted }],
|
() => [styles.actionText, { color: theme.colors.foregroundMuted }],
|
||||||
[theme.colors.foregroundMuted],
|
[theme.colors.foregroundMuted],
|
||||||
);
|
);
|
||||||
const submitActionTextColor = allAnswered
|
const submitActionTextColor = theme.colors.accentForeground;
|
||||||
? theme.colors.foreground
|
|
||||||
: theme.colors.foregroundMuted;
|
|
||||||
const submitActionTextStyle = useMemo(
|
const submitActionTextStyle = useMemo(
|
||||||
() => [styles.actionText, { color: submitActionTextColor }],
|
() => [styles.actionText, { color: submitActionTextColor }],
|
||||||
[submitActionTextColor],
|
[submitActionTextColor],
|
||||||
@@ -315,52 +403,76 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dismissLabel = resolveDismissLabel(questions);
|
const dismissLabel = resolveDismissLabel(questions);
|
||||||
|
const selected = selections[resolvedActiveQuestionIndex] ?? new Set<number>();
|
||||||
|
const otherText = otherTexts[resolvedActiveQuestionIndex] ?? "";
|
||||||
|
const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle}>
|
<View style={containerStyle} testID="question-form-card">
|
||||||
{questions.map((q, qIndex) => {
|
<View style={styles.questionTopRow}>
|
||||||
const selected = selections[qIndex] ?? new Set<number>();
|
<View style={styles.questionHeader}>
|
||||||
const otherText = otherTexts[qIndex] ?? "";
|
<Text testID="question-form-current-question" style={questionTextStyle}>
|
||||||
const showTextInput = questionShowsTextInput(q);
|
{activeQuestion?.question}
|
||||||
|
</Text>
|
||||||
return (
|
</View>
|
||||||
<View key={q.question} style={styles.questionBlock}>
|
<View style={questionNavStyle} testID="question-form-question-nav">
|
||||||
<View style={styles.questionHeader}>
|
{questions.map((question, qIndex) => {
|
||||||
<Text style={questionTextStyle}>{q.question}</Text>
|
const isActive = qIndex === resolvedActiveQuestionIndex;
|
||||||
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
|
return (
|
||||||
</View>
|
<QuestionNavButton
|
||||||
{q.options.length > 0 ? (
|
key={question.header}
|
||||||
<View style={styles.optionsWrap}>
|
index={qIndex}
|
||||||
{q.options.map((opt, optIndex) => (
|
total={questions.length}
|
||||||
<QuestionOptionRow
|
isActive={isActive}
|
||||||
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)}
|
|
||||||
isResponding={isResponding}
|
isResponding={isResponding}
|
||||||
onChange={setOtherText}
|
onSelect={handleSelectQuestion}
|
||||||
onSubmit={handleSubmit}
|
|
||||||
/>
|
/>
|
||||||
) : 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}>
|
<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" ? (
|
{respondingAction === "dismiss" ? (
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
) : (
|
) : (
|
||||||
@@ -371,9 +483,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
)}
|
)}
|
||||||
</Pressable>
|
</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" ? (
|
{respondingAction === "submit" ? (
|
||||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
<ActivityIndicator size="small" color={theme.colors.accentForeground} />
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.actionContent}>
|
<View style={styles.actionContent}>
|
||||||
<Check size={14} color={submitActionTextColor} />
|
<Check size={14} color={submitActionTextColor} />
|
||||||
@@ -396,21 +515,50 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
questionBlock: {
|
questionBlock: {
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
questionTopRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
questionHeader: {
|
questionHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingBottom: theme.spacing[1],
|
paddingBottom: theme.spacing[1],
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
questionText: {
|
questionText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
lineHeight: 22,
|
lineHeight: 22,
|
||||||
},
|
},
|
||||||
optionsWrap: {
|
optionsWrap: {
|
||||||
gap: theme.spacing[1],
|
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: {
|
optionItem: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
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));
|
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): {
|
function resolveModelProfile(modelId: string | null | undefined): {
|
||||||
modelId: string;
|
modelId: string;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
@@ -524,6 +528,8 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
const stress = parseAgentStreamStressPrompt(prompt);
|
const stress = parseAgentStreamStressPrompt(prompt);
|
||||||
if (shouldEmitPlanApprovalPrompt(prompt)) {
|
if (shouldEmitPlanApprovalPrompt(prompt)) {
|
||||||
this.schedulePlanApprovalTurn(turn);
|
this.schedulePlanApprovalTurn(turn);
|
||||||
|
} else if (shouldEmitQuestionPrompt(prompt)) {
|
||||||
|
this.scheduleQuestionPromptTurn(turn);
|
||||||
} else if (largePayload) {
|
} else if (largePayload) {
|
||||||
this.scheduleLargePayloadTurn(turn, largePayload);
|
this.scheduleLargePayloadTurn(turn, largePayload);
|
||||||
} else if (stress) {
|
} else if (stress) {
|
||||||
@@ -576,9 +582,11 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
response: AgentPermissionResponse,
|
response: AgentPermissionResponse,
|
||||||
): Promise<AgentPermissionResult | void> {
|
): Promise<AgentPermissionResult | void> {
|
||||||
if (!this.pendingPermissions.delete(requestId)) {
|
const request = this.pendingPermissions.get(requestId);
|
||||||
|
if (!request) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
this.pendingPermissions.delete(requestId);
|
||||||
|
|
||||||
const turn = this.activeTurn;
|
const turn = this.activeTurn;
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -590,7 +598,12 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (turn) {
|
if (turn) {
|
||||||
this.finishTurnWithText(turn, "Synthetic plan approval resolved");
|
this.finishTurnWithText(
|
||||||
|
turn,
|
||||||
|
request.kind === "question"
|
||||||
|
? "Synthetic questions resolved"
|
||||||
|
: "Synthetic plan approval resolved",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -689,6 +702,13 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
turn.timer.unref?.();
|
turn.timer.unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleQuestionPromptTurn(turn: ActiveTurn): void {
|
||||||
|
turn.timer = setTimeout(() => {
|
||||||
|
this.emitQuestionPromptTurn(turn);
|
||||||
|
}, 0);
|
||||||
|
turn.timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
private emitPlanApprovalTurn(turn: ActiveTurn): void {
|
private emitPlanApprovalTurn(turn: ActiveTurn): void {
|
||||||
if (this.activeTurn !== turn) {
|
if (this.activeTurn !== turn) {
|
||||||
return;
|
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 {
|
private emitStressTurn(turn: ActiveTurn, stress: AgentStreamStressRequest): void {
|
||||||
if (this.activeTurn !== turn) {
|
if (this.activeTurn !== turn) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user