From 16fe07560f58ae0b35ccedaf40d04f426833b2b3 Mon Sep 17 00:00:00 2001 From: Alessio Re Date: Mon, 26 Oct 2026 04:51:44 +0100 Subject: [PATCH] fix(app): make question cards link-aware and selectable without accidental answers Render question bodies and option text with the shared Markdown renderer. Opening links or selecting question and description text leaves answers unchanged. Labels remain answer controls, and double-clicking a label advances only once. Add browser regression coverage and stabilize Markdown WASM loading in the test harness. Closes #3164. Co-authored-by: Alessio Re --- .../question-prompt-pagination.spec.ts | 212 +++++++++++++++++- .../app/src/components/markdown/renderer.tsx | 25 ++- .../app/src/components/markdown/styles.ts | 8 +- .../question-form-card.browser.test.tsx | 6 + .../app/src/components/question-form-card.tsx | 77 ++++--- packages/app/test-stubs/expo-clipboard.ts | 10 + .../app/test-stubs/lucide-react-native.ts | 5 + packages/app/vitest.config.ts | 19 ++ .../agent/providers/mock-load-test-agent.ts | 29 +++ 9 files changed, 348 insertions(+), 43 deletions(-) create mode 100644 packages/app/test-stubs/expo-clipboard.ts diff --git a/packages/app/e2e/browser/question-prompt-pagination.spec.ts b/packages/app/e2e/browser/question-prompt-pagination.spec.ts index b444a8cddf..90a87433ed 100644 --- a/packages/app/e2e/browser/question-prompt-pagination.spec.ts +++ b/packages/app/e2e/browser/question-prompt-pagination.spec.ts @@ -1,6 +1,6 @@ import { fork } from "node:child_process"; import path from "node:path"; -import { expect } from "@playwright/test"; +import { expect, type BrowserContext, type Locator, type Page } from "@playwright/test"; import { z } from "zod"; import { test } from "../support/fixtures"; import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream"; @@ -34,8 +34,218 @@ const ROLLOUT_QUESTION = "Which rollout should we use?"; const SUCCESS_QUESTION = "What success criteria should we use?"; const REPO_URL_QUESTION = "What is the GitHub private repo URL to push to?"; const COMMIT_MESSAGE_QUESTION = "What should the first commit message be?"; +const LINKED_QUESTION = "Sign in at https://example.com/login, then follow the setup guide. Done?"; + +function selectedText(page: Page) { + return page.evaluate(() => window.getSelection()?.toString().trim()); +} + +const CHECKS_QUESTION = "Which checks should run?"; +const DONE_DESCRIPTION = "Logs are at https://example.com/logs if sign-in fails."; +const LINT_DESCRIPTION = "Rules: https://example.com/lint"; + +async function openLinkedQuestions(page: Page, context: BrowserContext) { + await context.route("https://example.com/**", (route) => + route.fulfill({ status: 200, body: "ok" }), + ); + const session = await seedMockAgentWorkspace({ + repoPrefix: "question-option-links-", + title: "Question option links e2e", + initialPrompt: "Emit synthetic questions with links.", + }); + await openAgentRoute(page, session); + await waitForQuestionPrompt(page, 120_000); + return { session, card: page.getByTestId("question-form-card").first() }; +} + +async function dragAcross(page: Page, target: Locator) { + const bounds = await target.boundingBox(); + if (!bounds) throw new Error("Text is not visible"); + const y = bounds.y + bounds.height / 2; + await page.mouse.move(bounds.x + 1, y); + await page.mouse.down(); + await page.mouse.move(bounds.x + bounds.width - 1, y, { steps: 12 }); + await page.mouse.up(); +} + +async function clickLinkAndExpectPage(context: BrowserContext, link: Locator, url: string) { + const popup = context.waitForEvent("page"); + await link.click(); + await expect.poll(async () => (await popup).url()).toBe(url); +} + +async function expectChecked(option: Locator, checked: boolean) { + await expect(option).toHaveAttribute("aria-checked", String(checked)); +} + +test.describe("Question option text", () => { + test("links in an option label or description open without answering", async ({ + page, + context, + }) => { + test.setTimeout(180_000); + const { session, card } = await openLinkedQuestions(page, context); + try { + const done = card.getByRole("radio", { name: "Done" }); + const notYet = card.getByRole("radio", { name: "Not yet" }); + + await clickLinkAndExpectPage( + context, + notYet.getByRole("link", { name: "status" }), + "https://example.com/status", + ); + await clickLinkAndExpectPage( + context, + done.getByRole("link", { name: "https://example.com/logs" }), + "https://example.com/logs", + ); + + await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION }); + await expectChecked(done, false); + await expectChecked(notYet, false); + } finally { + await session.cleanup(); + } + }); + + test("selecting description text never answers, and the label still does", async ({ + page, + context, + }) => { + test.setTimeout(180_000); + const { session, card } = await openLinkedQuestions(page, context); + try { + const done = card.getByRole("radio", { name: "Done" }); + const description = done.getByTestId("question-form-option-description"); + + await dragAcross(page, description); + await expect.poll(() => selectedText(page)).toBe(DONE_DESCRIPTION); + await expectChecked(done, false); + + await description.getByText("Logs are at", { exact: false }).dblclick({ + position: { x: 4, y: 4 }, + }); + await expect.poll(() => selectedText(page)).toBe("Logs"); + await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION }); + await expectChecked(done, false); + + await done.getByText("Done", { exact: true }).click(); + await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION }); + await openQuestion(page, { index: 1, total: 2 }); + await expectChecked(done, true); + } finally { + await session.cleanup(); + } + }); + + test("double-clicking a single-select label answers once", async ({ page, context }) => { + test.setTimeout(180_000); + const { session, card } = await openLinkedQuestions(page, context); + try { + await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).dblclick(); + + await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION }); + await expectChecked(card.getByRole("checkbox", { name: "Lint" }), false); + await expectChecked(card.getByRole("checkbox", { name: "Typecheck" }), false); + await openQuestion(page, { index: 1, total: 2 }); + await expectChecked(card.getByRole("radio", { name: "Done" }), true); + } finally { + await session.cleanup(); + } + }); + + test("multi-select description links and selection leave the option unchanged", async ({ + page, + context, + }) => { + test.setTimeout(180_000); + const { session, card } = await openLinkedQuestions(page, context); + try { + await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).click(); + await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION }); + + const lint = card.getByRole("checkbox", { name: "Lint" }); + const typecheck = card.getByRole("checkbox", { name: "Typecheck" }); + const description = lint.getByTestId("question-form-option-description"); + + await clickLinkAndExpectPage( + context, + description.getByRole("link", { name: "https://example.com/lint" }), + "https://example.com/lint", + ); + await expectChecked(lint, false); + + await dragAcross(page, description); + await expect.poll(() => selectedText(page)).toBe(LINT_DESCRIPTION); + await expectChecked(lint, false); + + await description.getByText("Rules:", { exact: false }).dblclick({ + position: { x: 4, y: 4 }, + }); + await expect.poll(() => selectedText(page)).toBe("Rules"); + await expectChecked(lint, false); + + await typecheck.getByText("Typecheck", { exact: true }).dblclick(); + await expectChecked(typecheck, true); + await lint.getByText("Lint", { exact: true }).click(); + await expectChecked(lint, true); + } finally { + await session.cleanup(); + } + }); +}); test.describe("Question prompt pagination", () => { + test("question links open without answering, and the question text is selectable", async ({ + page, + context, + }) => { + test.setTimeout(180_000); + await context.route("https://example.com/**", (route) => + route.fulfill({ status: 200, body: "ok" }), + ); + + const session = await seedMockAgentWorkspace({ + repoPrefix: "question-links-", + title: "Question links e2e", + initialPrompt: "Emit synthetic questions with links.", + }); + + try { + await openAgentRoute(page, session); + await waitForQuestionPrompt(page, 120_000); + + const card = page.getByTestId("question-form-card").first(); + const question = card.getByTestId("question-form-current-question"); + await expect(question).toHaveText(LINKED_QUESTION); + await expect(question.getByRole("link", { name: "https://example.com/login" })).toBeVisible(); + await expect(question.getByRole("link", { name: "the setup guide" })).toBeVisible(); + + const bounds = await question.boundingBox(); + if (!bounds) throw new Error("Question text is not visible"); + const y = bounds.y + bounds.height / 2; + await page.mouse.move(bounds.x + 1, y); + await page.mouse.down(); + await page.mouse.move(bounds.x + bounds.width - 1, y, { steps: 12 }); + await page.mouse.up(); + await expect.poll(() => selectedText(page)).toBe(LINKED_QUESTION); + + const popup = context.waitForEvent("page"); + await question.getByRole("link", { name: "the setup guide" }).click(); + await expect.poll(async () => (await popup).url()).toBe("https://example.com/setup"); + + await expect(page.getByTestId("question-form-card")).toHaveCount(1); + for (const option of ["Done", "Not yet"]) { + await expect(card.getByRole("radio", { name: option })).toHaveAttribute( + "aria-checked", + "false", + ); + } + } finally { + await session.cleanup(); + } + }); + test("shows one question at a time with numbered navigation", async ({ page }) => { test.setTimeout(180_000); diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx index 6d79beb01f..8e51495369 100644 --- a/packages/app/src/components/markdown/renderer.tsx +++ b/packages/app/src/components/markdown/renderer.tsx @@ -23,14 +23,29 @@ const ThemedMarkdown = withUnistyles(EnrichedMarkdownText); const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true }; // Reading the breakpoint here makes Unistyles restyle the text when the layout crosses it. -const markdownStyle = (compact: boolean) => (theme: Theme, rt: { breakpoint?: string }) => - createMarkdownStyles(theme, { compact, blockGap: resolveDensity(rt, density.blockGap) }); +const markdownStyle = + (compact: boolean, muted = false) => + (theme: Theme, rt: { breakpoint?: string }) => + createMarkdownStyles(theme, { + compact, + muted, + blockGap: resolveDensity(rt, density.blockGap), + }); const regularStyle = markdownStyle(false); const compactStyle = markdownStyle(true); +const compactMutedStyle = markdownStyle(true, true); + +function pickMarkdownStyle(compact: boolean, muted: boolean) { + if (!compact) return regularStyle; + return muted ? compactMutedStyle : compactStyle; +} export interface MarkdownRendererProps extends MarkdownImageContext { text: string; compact?: boolean; + /** Muted text colour. Compact surfaces only. */ + muted?: boolean; + selectable?: boolean; streaming?: boolean; onLinkPress?: (url: string) => unknown; } @@ -56,6 +71,8 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe const MarkdownPart = memo(function MarkdownPart({ text, compact = false, + muted = false, + selectable = true, streaming = false, onLinkPress, ...imageContext @@ -98,9 +115,9 @@ const MarkdownPart = memo(function MarkdownPart({ ({ diff --git a/packages/app/src/components/question-form-card.browser.test.tsx b/packages/app/src/components/question-form-card.browser.test.tsx index 83120931d1..0b2a5df25d 100644 --- a/packages/app/src/components/question-form-card.browser.test.tsx +++ b/packages/app/src/components/question-form-card.browser.test.tsx @@ -10,6 +10,12 @@ import { QuestionFormCard } from "./question-form-card"; // Load translations so controls expose their real accessible names. void testI18n; +// The Markdown engine does not load in this harness; question and option text are covered by +// e2e/browser/question-prompt-pagination.spec.ts. Plain text keeps these tests on the answers. +vi.mock("@/components/markdown/renderer", () => ({ + MarkdownRenderer: ({ text }: { text: string }) => {text}, +})); + // App sources compile against the classic JSX runtime, which expects React on the global. beforeEach(() => vi.stubGlobal("React", React)); diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 7014de89d1..8ff81963d1 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -1,7 +1,13 @@ import { Alert } from "@/components/ui/alert"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useState, useCallback, useMemo, useRef, type RefObject } from "react"; -import { View, Text, Pressable, type PressableStateCallbackType } from "react-native"; +import { + View, + Text, + Pressable, + type GestureResponderEvent, + type PressableStateCallbackType, +} from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, X } from "lucide-react-native"; @@ -12,6 +18,7 @@ import { isWeb } from "@/constants/platform"; import { EditingTextInput as TextInput } from "@/components/ui/text-input"; import type { EditingTextInputHandle } from "@/components/ui/text-input/types"; import { ScrollableCodeSurface } from "@/components/ui/scrollable-code-surface"; +import { MarkdownRenderer } from "@/components/markdown/renderer"; import { areQuestionsAnswered, buildQuestionFormAnswers, @@ -36,6 +43,20 @@ const IS_WEB = isWeb; // drag to the preview's nested scroll, so lines past a cap would be unreachable. const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined; +// Web clicks that must not answer: a link in the label, anything in the description, and the +// second click of a double-click, which would otherwise land on the next question's options. +function isNonAnsweringClick(event: GestureResponderEvent, description: View | null): boolean { + if (!IS_WEB) return false; + const click = event.nativeEvent as unknown as MouseEvent; + if (click.detail > 1) return true; + const target = click.target as Element | null; + const descriptionElement = description as unknown as Element | null; + return Boolean(target?.closest("a") || descriptionElement?.contains(target)); +} + +// The description is selectable text, so a touch on it belongs to the text, not the option. +const claimResponder = () => true; + function getQuestionInputPlaceholder({ question, answerPlaceholder, @@ -71,9 +92,14 @@ function QuestionOptionRow({ }: QuestionOptionRowProps) { const { theme } = useUnistyles(); - const handlePress = useCallback(() => { - onToggle(qIndex, optIndex, multiSelect); - }, [onToggle, qIndex, optIndex, multiSelect]); + const descriptionRef = useRef(null); + const handlePress = useCallback( + (event: GestureResponderEvent) => { + if (isNonAnsweringClick(event, descriptionRef.current)) return; + onToggle(qIndex, optIndex, multiSelect); + }, + [onToggle, qIndex, optIndex, multiSelect], + ); const pressableStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ @@ -86,17 +112,6 @@ function QuestionOptionRow({ [isSelected, theme.colors.surface2], ); - const optionLabelStyle = useMemo( - () => [ - styles.optionLabel, - { color: isSelected ? theme.colors.foreground : theme.colors.foregroundMuted }, - ], - [isSelected, theme.colors.foreground, theme.colors.foregroundMuted], - ); - const optionDescriptionStyle = useMemo( - () => [styles.optionDescription, { color: theme.colors.foregroundMuted }], - [theme.colors.foregroundMuted], - ); const accessibilityState = useMemo(() => ({ checked: isSelected }), [isSelected]); // Static left-side control: square for multi-select, circle for single-select. @@ -135,9 +150,15 @@ function QuestionOptionRow({ {isSelected && !multiSelect ? : null} - {option.label} + {option.description ? ( - {option.description} + + + ) : null} {option.preview ? ( // Shown for every option at once so they can be compared before picking. @@ -522,10 +543,6 @@ export function QuestionFormCard({ ], [theme.colors.surface1, theme.colors.border], ); - const questionTextStyle = useMemo( - () => [styles.questionText, { color: theme.colors.foreground }], - [theme.colors.foreground], - ); // Single-select radios need a group; checkboxes are valid standalone. const optionsGroupAccessibility = useMemo( () => @@ -570,9 +587,9 @@ export function QuestionFormCard({ onSelect={handleSelectQuestion} /> - - {activeQuestion?.question} - + + + {activeQuestion ? ( @@ -675,9 +692,6 @@ const styles = StyleSheet.create((theme) => ({ }, questionText: { flex: 1, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - lineHeight: 22, }, optionsWrap: { gap: theme.spacing[1], @@ -723,15 +737,6 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, gap: theme.spacing[1], }, - optionLabel: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - lineHeight: 22, - }, - optionDescription: { - fontSize: theme.fontSize.base, - lineHeight: 20, - }, optionPreviewText: { lineHeight: 20, ...(isWeb ? { whiteSpace: "pre-wrap", overflowWrap: "break-word" } : null), diff --git a/packages/app/test-stubs/expo-clipboard.ts b/packages/app/test-stubs/expo-clipboard.ts new file mode 100644 index 0000000000..9663320efb --- /dev/null +++ b/packages/app/test-stubs/expo-clipboard.ts @@ -0,0 +1,10 @@ +// The package ships JSX in .js files, which the dependency optimizer cannot parse. +export async function setStringAsync(): Promise { + return true; +} +export async function getStringAsync(): Promise { + return ""; +} +export async function hasStringAsync(): Promise { + return false; +} diff --git a/packages/app/test-stubs/lucide-react-native.ts b/packages/app/test-stubs/lucide-react-native.ts index 200048ad53..a85976e1bf 100644 --- a/packages/app/test-stubs/lucide-react-native.ts +++ b/packages/app/test-stubs/lucide-react-native.ts @@ -111,3 +111,8 @@ export const Upload = StubIcon; export const Wrench = StubIcon; export const X = StubIcon; export const XCircle = StubIcon; +export const Code = StubIcon; +export const Maximize2 = StubIcon; +export const Workflow = StubIcon; +export const ZoomIn = StubIcon; +export const ZoomOut = StubIcon; diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 2d7149442e..2b56646b2a 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -19,6 +19,20 @@ const resolvePackageEntry = (packageName: string) => { export default defineConfig({ plugins: [ + { + name: "markdown-wasm-commonjs-export", + transform(source, id) { + // The ESM package contains a CommonJS WASM factory. Metro handles the + // interop; Vite needs an ESM export instead of the CommonJS trailer. + if (!id.endsWith("/lib/module/web/wasm/md4c.js")) return; + const trailer = source.lastIndexOf("\nif (typeof exports === 'object'"); + if (trailer < 0) throw new Error("Markdown WASM CommonJS trailer was not found"); + return { + code: `${source.slice(0, trailer)}\nexport default createMd4cModule;`, + map: null, + }; + }, + }, { name: "compile-usage-components", enforce: "pre", @@ -151,6 +165,7 @@ export default defineConfig({ "react-native-gesture-handler > invariant", ], exclude: [ + "react-native-enriched-markdown", "react-native-reanimated", "react-native-gesture-handler", "react-native-keyboard-controller", @@ -254,6 +269,10 @@ export default defineConfig({ find: /^react-native-reanimated\/scripts\/validate-worklets-version$/, replacement: path.resolve(__dirname, "test-stubs/reanimated-validate-worklets-version.ts"), }, + { + find: /^expo-clipboard$/, + replacement: path.resolve(__dirname, "test-stubs/expo-clipboard.ts"), + }, { find: /^expo-linking$/, replacement: path.resolve(__dirname, "test-stubs/expo-linking.ts"), 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 b7b301540a..7055030dc0 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 @@ -295,6 +295,35 @@ function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRe return null; } + if (/with\s+links/i.test(text)) { + return { + questions: [ + { + question: + "Sign in at https://example.com/login, then follow [the setup guide](https://example.com/setup). Done?", + header: "signIn", + options: [ + { + label: "Done", + description: "Logs are at https://example.com/logs if sign-in fails.", + }, + { label: "Not yet, see [status](https://example.com/status)" }, + ], + multiSelect: false, + }, + { + question: "Which checks should run?", + header: "checks", + options: [ + { label: "Lint", description: "Rules: https://example.com/lint" }, + { label: "Typecheck" }, + ], + multiSelect: true, + }, + ], + }; + } + if (/free[-\s]?write|freeform|text[-\s]?only/i.test(text)) { return { questions: [