From 033b2cdfc2b85e4e67a7f9d22b3af25200cf54a0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 26 Oct 2026 14:24:38 +0700 Subject: [PATCH] fix(app): keep command suggestions visible as the composer grows (#7910) Long command descriptions exhausted the available space above a tall draft, leaving no complete selectable row. Bound the preview and reserve list space. Let the popover own its composer anchor so layout changes refresh its position while suggestions remain open. --- docs/mobile-testing.md | 6 + .../autocomplete-placement.mjs | 9 +- .../app/e2e/mobile/composer-keyboard/ios.mjs | 161 +++++++++++++++--- .../components/ui/autocomplete-popover.tsx | 49 +++--- .../app/src/components/ui/autocomplete.tsx | 14 +- packages/app/src/composer/index.tsx | 36 ++-- 6 files changed, 207 insertions(+), 68 deletions(-) diff --git a/docs/mobile-testing.md b/docs/mobile-testing.md index 917ebf9b53..169803c0eb 100644 --- a/docs/mobile-testing.md +++ b/docs/mobile-testing.md @@ -94,6 +94,12 @@ and its Claude provider available. Set `PASEO_COMPOSER_KEYBOARD_SERVER_ID`, `_AGENT_ID` (an idle mock chat), and `_WORKSPACE_ID` (its workspace); `_DEVICE`, `_SESSION`, and `_DRAFT_ID` are optional. Artifacts go to `.dev/agent-device-artifacts/composer-keyboard-ios`. +With a Codex draft selected, set `_SCENARIO=growth` on a 402pt-wide iPhone to +check live wrapping, shrinking, the 12pt anchor gap, and command insertion. Set +`_SCENARIO=previews` on an SE-sized iPhone to check long and short descriptions +under an eight-line draft, selection with the keyboard shown and hidden, and +file mentions. These scenarios only need `_SERVER_ID` and `_WORKSPACE_ID`; +`_UDID` selects an exact simulator and `_ARTIFACTS_DIR` overrides the output path. The Android sidebar scroll-retention flow requires a connected host with enough workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and diff --git a/packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs b/packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs index 22c0b42e8f..a8450838eb 100644 --- a/packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs +++ b/packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs @@ -28,7 +28,10 @@ function contains(outer, inner) { * while suggestions are open, so its callers read the composer from `reference`: a * snapshot in the same keyboard state with an empty input. */ -export function resolveAutocompletePlacement(snapshot, { expected, keyboardTop, reference }) { +export function resolveAutocompletePlacement( + snapshot, + { expected, keyboardTop, reference, anchorGap }, +) { if (!snapshot.success || !snapshot.data?.nodes?.length) throw new Error("Empty device snapshot"); const nodes = snapshot.data.nodes; const popover = findById(nodes, "composer-autocomplete-popover").rect; @@ -40,6 +43,10 @@ export function resolveAutocompletePlacement(snapshot, { expected, keyboardTop, const keyboardY = keyboardTop ?? nodes.find((node) => node.type === "Keyboard" && node.rect)?.rect.y; const popoverBottom = bottomOf(popover); + const gap = composer.y - popoverBottom; + if (anchorGap !== undefined && Math.abs(gap - anchorGap) > 1) { + throw new Error(`Autocomplete lost its anchor gap: gap=${gap}, expected=${anchorGap}`); + } if (popoverBottom > composer.y + 1) { throw new Error( `Autocomplete overlaps the composer: popover bottom=${popoverBottom}, composer top=${composer.y}`, diff --git a/packages/app/e2e/mobile/composer-keyboard/ios.mjs b/packages/app/e2e/mobile/composer-keyboard/ios.mjs index e3fc4af072..513d68623e 100644 --- a/packages/app/e2e/mobile/composer-keyboard/ios.mjs +++ b/packages/app/e2e/mobile/composer-keyboard/ios.mjs @@ -14,7 +14,8 @@ const env = (name, fallback) => process.env[`PASEO_COMPOSER_KEYBOARD_${name}`] ? const serverId = env("SERVER_ID"); const agentId = env("AGENT_ID"); const workspaceId = env("WORKSPACE_ID"); -if (!serverId || !agentId || !workspaceId) { +const scenario = env("SCENARIO", "all"); +if (!serverId || !workspaceId || (scenario === "all" && !agentId)) { throw new Error( "Set PASEO_COMPOSER_KEYBOARD_SERVER_ID, _AGENT_ID (an idle chat) and _WORKSPACE_ID (its workspace)", ); @@ -22,7 +23,12 @@ if (!serverId || !agentId || !workspaceId) { const draftId = env("DRAFT_ID", "composer-keyboard"); const session = env("SESSION", "composer-keyboard-ios"); const device = env("DEVICE", "iPhone 16 Pro"); -const artifactsDir = path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios"); +const udid = env("UDID"); +const deviceArgs = udid ? ["--udid", udid] : ["--device", device]; +const artifactsDir = env( + "ARTIFACTS_DIR", + path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios"), +); const grownPrefix = "grown line one\ngrown line two\ngrown line three\n"; // Partial queries with known best matches. `inserted` is the composer text after // tapping the best match. @@ -35,7 +41,7 @@ const chatCommandCase = { text: "/mo", expected: "/mock", inserted: "/mock " }; fs.mkdirSync(artifactsDir, { recursive: true }); function ad(...args) { - return execFileSync("agent-device", [...args, "--session", session], { + return execFileSync("agent-device", [args[0], "--session", session, ...args.slice(1)], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, }); @@ -77,7 +83,7 @@ async function settledPlacement(name, keyboard) { } function assertPlacement(name, snap, expected) { - const placement = resolveAutocompletePlacement(snap, { expected }); + const placement = resolveAutocompletePlacement(snap, { expected, anchorGap: 12 }); process.stdout.write( `PASS ${name}: popover ${Math.round(placement.popover.y)}-${Math.round( placement.popover.y + placement.popover.height, @@ -123,21 +129,34 @@ async function clearInput(name) { )?.rect; if (!deleteKey) throw new Error("Missing keyboard delete key"); const length = composerText(keyboard).length; - ad( - "press", - String(Math.round(deleteKey.x + deleteKey.width / 2)), - String(Math.round(deleteKey.y + deleteKey.height / 2)), - "--count", - String(length + 2), - "--interval-ms", - "40", - ); + for (let remaining = length + 2; remaining > 0; remaining -= 100) { + ad( + "press", + String(Math.round(deleteKey.x + deleteKey.width / 2)), + String(Math.round(deleteKey.y + deleteKey.height / 2)), + "--count", + String(Math.min(remaining, 100)), + "--interval-ms", + "40", + ); + } } // The accessibility value drops a trailing space. const sameText = (actual, expected) => actual.trimEnd() === expected.trimEnd(); -async function runCase(host, kind, { text, expected, inserted }) { +async function selectRow(name, row, inserted) { + ad("press", String(row.x), String(row.y)); + const selected = await waitFor(`${name}-selected`, (snap) => + sameText(composerText(snap), inserted), + ); + if (!keyboardShown(selected)) throw new Error(`Selecting ${row.label} closed the keyboard`); + process.stdout.write( + `PASS ${name}: tapped ${row.label}, composer now ${JSON.stringify(inserted)}\n`, + ); +} + +async function runCase(host, kind, { text, expected, inserted, selectionKeyboard = "reopened" }) { const name = `${host}-${kind}`; await clearInput(name); await dismissKeyboard(snapshot(`${name}-empty`)); @@ -151,31 +170,32 @@ async function runCase(host, kind, { text, expected, inserted }) { await clearInput(`${name}-settled`); await waitFor(`${name}-settled-empty`, (snap) => composerText(snap) === ""); ad("type", text); - assertPlacement( + const opened = assertPlacement( `${name} opened after keyboard settled`, await settledPlacement(`${name}-open`, true), expected, ); + if (selectionKeyboard === "shown") { + await selectRow(name, opened, inserted); + return; + } await dismissKeyboard(snapshot(`${name}-before-close`)); - assertPlacement( + const closed = assertPlacement( `${name} keyboard closed`, await settledPlacement(`${name}-closed`, false), expected, ); + if (selectionKeyboard === "hidden") { + await selectRow(name, closed, inserted); + return; + } focusAtEnd(snapshot(`${name}-before-reopen`)); const reopened = assertPlacement( `${name} keyboard opened under open suggestions`, await settledPlacement(`${name}-reopened`, true), expected, ); - ad("press", String(reopened.x), String(reopened.y)); - const selected = await waitFor(`${name}-selected`, (snap) => - sameText(composerText(snap), inserted), - ); - if (!keyboardShown(selected)) throw new Error(`Selecting ${expected} closed the keyboard`); - process.stdout.write( - `PASS ${name}: tapped ${expected}, composer now ${JSON.stringify(inserted)}\n`, - ); + await selectRow(name, reopened, inserted); } // Drafts list commands for their selected provider, and a fresh install selects none. @@ -216,13 +236,104 @@ async function runHost(host) { await dismissKeyboard(snapshot(`${host}-done`)); } +// These cases use the selected draft provider's real skills. Run on a 402pt-wide +// iPhone for growth and a small phone (SE) for previews, with Codex selected. +async function runLiveGrowth() { + await clearInput("live-growth"); + ad("type", "Please do the following now /paseo"); + const before = await settledPlacement("live-growth-before", true); + assertPlacement("before growth", before, "/paseo"); + const inputHeight = (snap) => + snap.data.nodes.find((node) => node.type === "TextView").rect.height; + ad("type", "--", "-committee"); + const grown = await settledPlacement("live-growth-after", true); + if (inputHeight(grown) <= inputHeight(before)) + throw new Error("Draft did not wrap onto a new line"); + assertPlacement("live growth without reopening", grown, "/paseo-committee"); + const deleteKey = grown.data.nodes.find( + (node) => node.type === "Key" && node.label === "delete", + ).rect; + ad( + "press", + String(deleteKey.x + deleteKey.width / 2), + String(deleteKey.y + deleteKey.height / 2), + "--count", + "10", + "--interval-ms", + "40", + ); + const shrunk = await settledPlacement("live-growth-shrunk", true); + if (inputHeight(shrunk) !== inputHeight(before)) + throw new Error("Draft did not shrink back to one line"); + assertPlacement("live shrink without reopening", shrunk, "/paseo"); + ad("type", "--", "-committ"); + const partial = await settledPlacement("live-growth-partial", true); + if (!sameText(composerText(partial), "Please do the following now /paseo-committ")) { + throw new Error("Could not prepare a partial command for selection"); + } + const row = assertPlacement("partial command after growth", partial, "/paseo-committee"); + ad("press", String(row.x), String(row.y)); + await waitFor("live-growth-selected", (snap) => + sameText(composerText(snap), "Please do the following now /paseo-committee "), + ); + await clearInput("live-growth-done"); +} + +async function runBoundedPreview() { + const prefix = + "Please do the following / testing inline selection.\n" + + "Line two with details.\nLine three with details.\nLine four with details.\n" + + "Line five with details.\nLine six with details.\nLine seven with details.\nPlease use "; + await runCase("preview", "long", { + text: `${prefix}/openai`, + expected: "/openai-docs", + inserted: `${prefix}/openai-docs `, + selectionKeyboard: "shown", + }); + await runCase("preview", "long-hidden", { + text: `${prefix}/openai`, + expected: "/openai-docs", + inserted: `${prefix}/openai-docs `, + selectionKeyboard: "hidden", + }); + await runCase("preview", "longest", { + text: `${prefix}/paseo-plug`, + expected: "/paseo-plugin", + inserted: `${prefix}/paseo-plugin `, + selectionKeyboard: "shown", + }); + await runCase("preview", "short", { + text: `${prefix}/com`, + expected: "/commit", + inserted: `${prefix}/commit `, + selectionKeyboard: "shown", + }); + await runCase("preview", "files", fileCase); +} + +if (scenario !== "all") { + ad( + "open", + `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`, + "--platform", + "ios", + ...deviceArgs, + ); + await waitFor("draft", (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000); + if (scenario === "growth") await runLiveGrowth(); + else if (scenario === "previews") await runBoundedPreview(); + else throw new Error(`Unknown scenario: ${scenario}`); + process.stdout.write(`Composer ${scenario} regression passed. Artifacts: ${artifactsDir}\n`); + process.exit(0); +} + const hosts = [ ["chat", `paseo://h/${serverId}/agent/${agentId}`], ["workspace-draft", `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`], ["new-workspace", `paseo://new?serverId=${serverId}`], ]; for (const [host, url] of hosts) { - ad("open", url, "--platform", "ios", "--device", device); + ad("open", url, "--platform", "ios", ...deviceArgs); await waitFor(host, (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000); await runHost(host); } diff --git a/packages/app/src/components/ui/autocomplete-popover.tsx b/packages/app/src/components/ui/autocomplete-popover.tsx index fa5e724944..e74c068658 100644 --- a/packages/app/src/components/ui/autocomplete-popover.tsx +++ b/packages/app/src/components/ui/autocomplete-popover.tsx @@ -5,9 +5,9 @@ import { useRef, useState, type ReactElement, - type RefObject, + type ReactNode, } from "react"; -import { View, useWindowDimensions } from "react-native"; +import { View, useWindowDimensions, type StyleProp, type ViewStyle } from "react-native"; import { Portal } from "@gorhom/portal"; import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; @@ -32,7 +32,8 @@ interface RelativeAnchorRect { interface AutocompletePopoverProps { visible: boolean; - anchorRef: RefObject; + children: ReactNode; + anchorStyle?: StyleProp; options: readonly AutocompleteOption[]; selectedIndex: number; onSelect: (option: AutocompleteOption) => void; @@ -44,7 +45,8 @@ interface AutocompletePopoverProps { export function AutocompletePopover({ visible, - anchorRef, + children, + anchorStyle, options, selectedIndex, onSelect, @@ -55,6 +57,7 @@ export function AutocompletePopover({ }: AutocompletePopoverProps): ReactElement | null { "use no memo"; // React Compiler memoizes effect captures by reading SharedValue.value during render. + const anchorRef = useRef(null); const [relativeAnchorRect, setRelativeAnchorRect] = useState(null); const windowDimensions = useWindowDimensions(); const safeAreaInsets = useSafeAreaInsets(); @@ -137,25 +140,29 @@ export function AutocompletePopover({ [anchorY, reservedLift, safeAreaInsets.top], ); - if (!visible || !relativeAnchorRect || !baseStyle) return null; - if (options.length > 0 && selectedIndex < 0) return null; + const showPopover = canMeasure && relativeAnchorRect && baseStyle; // The translate view is the popover's own frame, so it lifts only the popover. return ( - - - - - - - + + {children} + {showPopover ? ( + + + + + + + + ) : null} + ); } diff --git a/packages/app/src/components/ui/autocomplete.tsx b/packages/app/src/components/ui/autocomplete.tsx index 7b5b99b316..a9cb10f33e 100644 --- a/packages/app/src/components/ui/autocomplete.tsx +++ b/packages/app/src/components/ui/autocomplete.tsx @@ -247,14 +247,16 @@ export function Autocomplete({ {selectedOption?.kind === "command" && selectedOption.description ? ( - + {removeBoltGlyphs(selectedOption.label) ?? selectedOption.label} - + {removeBoltGlyphs(selectedOption.description)} {selectedOption.detail ? ( - {removeBoltGlyphs(selectedOption.detail)} + + {removeBoltGlyphs(selectedOption.detail)} + ) : null} ) : null} @@ -293,6 +295,9 @@ const styles = StyleSheet.create((theme: Theme) => ({ gap: theme.spacing[1], }, detailCard: { + flexShrink: 1, + minHeight: 0, + overflow: "hidden", backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, @@ -318,7 +323,8 @@ const styles = StyleSheet.create((theme: Theme) => ({ }, container: { flexShrink: 1, - minHeight: 0, + // Keep a complete selectable row (36), list padding (8), and borders (2). + minHeight: 46, backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index ba85de2516..d000c269af 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1003,7 +1003,8 @@ function ComposerAutocompleteBinding({ recall, configuration, inputRef, - anchorRef, + children, + anchorStyle, show, ref, }: { @@ -1015,7 +1016,8 @@ function ComposerAutocompleteBinding({ "userInput" | "cursorIndex" | "onAutocompleteApplied" >; inputRef: React.RefObject; - anchorRef: React.RefObject; + children: React.ReactNode; + anchorStyle: React.ComponentProps["style"]; show: boolean; ref: React.Ref; }) { @@ -1042,7 +1044,7 @@ function ComposerAutocompleteBinding({ return ( + > + {children} + ); } @@ -2379,7 +2383,6 @@ function ComposerContentImpl({ pluginClientSlashCommands, ], ); - const messageInputContainerRef = useRef(null); const isSubmitLoadingVisible = isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0; @@ -2438,17 +2441,16 @@ function ComposerContentImpl({ {isFullscreen ? null : queueList} {!isFullscreen && sendErrorNode} - - + {pluginAttachments.picker} - +