From 91b05b41114a5a131be556a30bb4d2ea1c227a4e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 26 May 2026 09:35:28 +0700 Subject: [PATCH] Fix autocomplete popover flicker --- .../app/e2e/composer-autocomplete.spec.ts | 458 ++++++++++++++++++ .../components/ui/autocomplete-popover.tsx | 32 +- .../app/src/components/ui/autocomplete.tsx | 3 +- .../app/src/hooks/use-agent-autocomplete.ts | 6 +- 4 files changed, 469 insertions(+), 30 deletions(-) create mode 100644 packages/app/e2e/composer-autocomplete.spec.ts diff --git a/packages/app/e2e/composer-autocomplete.spec.ts b/packages/app/e2e/composer-autocomplete.spec.ts new file mode 100644 index 000000000..7ebaf6e26 --- /dev/null +++ b/packages/app/e2e/composer-autocomplete.spec.ts @@ -0,0 +1,458 @@ +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { expect, test, type Page } from "./fixtures"; +import { composerLocator, expectComposerVisible } from "./helpers/composer"; +import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf"; +import { createTempGitRepo } from "./helpers/workspace"; +import { expectWorkspaceTabVisible } from "./helpers/archive-tab"; + +const TEST_COMMANDS = [ + { + name: "tdd", + description: "Write a red test, verify it fails for the right reason, implement to green", + argumentHint: "", + }, + { + name: "help", + description: "Show help for the current agent session and available slash commands", + argumentHint: "", + }, + { + name: "hello", + description: "Insert a friendly greeting prompt into the current composer", + argumentHint: "", + }, + { + name: "heapdump", + description: "Dump the JavaScript heap for local desktop debugging", + argumentHint: "", + }, + { + name: "health", + description: "Show runtime health checks and connection diagnostics", + argumentHint: "", + }, + { + name: "history", + description: "Summarize recent session history", + argumentHint: "", + }, + { + name: "handoff", + description: "Prepare a complete handoff note for another agent", + argumentHint: "[agent]", + }, + { + name: "hover", + description: "Audit hover behavior in desktop web surfaces", + argumentHint: "", + }, + { + name: "harness", + description: "Inspect the local test harness configuration", + argumentHint: "", + }, + { + name: "hydrate", + description: "Refresh persisted state used by the current workspace", + argumentHint: "", + }, + { + name: "highlight", + description: "Highlight important changes in the active diff", + argumentHint: "", + }, + { + name: "home", + description: "Navigate back to the workspace home surface", + argumentHint: "", + }, + { + name: "host", + description: "Inspect host connection metadata", + argumentHint: "", + }, +] as const; + +interface PopoverFrame { + exists: boolean; + top: number; + bottom: number; + height: number; + opacity: number; + display: string; + visibility: string; + timestamp: number; +} + +interface PopoverFrameRecorderWindow extends Window { + __composerAutocompleteFrames?: PopoverFrame[]; + __stopComposerAutocompleteFrameRecorder?: () => void; +} + +function getServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + return serverId; +} + +function getDaemonPort(): string { + const daemonPort = process.env.E2E_DAEMON_PORT; + if (!daemonPort || daemonPort === "6767") { + throw new Error("E2E_DAEMON_PORT must point at the isolated e2e daemon."); + } + return daemonPort; +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +async function installListCommandsStub(page: Page): Promise { + await page.routeWebSocket(new RegExp(`:${escapeRegex(getDaemonPort())}\\b`), (ws) => { + const server = ws.connectToServer(); + + ws.onMessage((message) => { + server.send(message); + }); + + server.onMessage((message) => { + if (typeof message !== "string") { + ws.send(message); + return; + } + + try { + const parsed = JSON.parse(message) as { + type?: string; + message?: { + type?: string; + payload?: { + commands?: unknown; + error?: string | null; + }; + }; + }; + if ( + parsed.type === "session" && + parsed.message?.type === "list_commands_response" && + parsed.message.payload + ) { + parsed.message.payload.commands = TEST_COMMANDS; + parsed.message.payload.error = null; + ws.send(JSON.stringify(parsed)); + return; + } + } catch { + // Forward non-JSON frames unchanged. + } + + ws.send(message); + }); + }); +} + +async function openProject(client: TerminalPerfDaemonClient, cwd: string): Promise { + const opened = await client.openProject(cwd); + if (!opened.workspace) { + throw new Error(opened.error ?? `Failed to open project ${cwd}`); + } +} + +async function cleanupWithin(timeoutMs: number, cleanup: () => Promise): Promise { + const operation = cleanup().catch(() => undefined); + await Promise.race([operation, new Promise((resolve) => setTimeout(resolve, timeoutMs))]); +} + +async function openReadyMockAgent(page: Page): Promise<{ + cleanup: () => Promise; +}> { + const repo = await createTempGitRepo("autocomplete-popover-"); + const client = await connectTerminalClient(); + + try { + await openProject(client, repo.path); + const agent = await client.createAgent({ + provider: "mock", + cwd: repo.path, + title: "Autocomplete popover regression", + modeId: "load-test", + model: "ten-second-stream", + }); + const route = `${buildHostWorkspaceRoute( + getServerId(), + repo.path, + )}?open=${encodeURIComponent(`agent:${agent.id}`)}`; + await page.goto(route); + await page.waitForURL( + (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), + { timeout: 60_000 }, + ); + await expectWorkspaceTabVisible(page, agent.id); + await expectComposerVisible(page); + return { + cleanup: async () => { + await cleanupWithin(2_000, () => client.close()); + await cleanupWithin(2_000, () => repo.cleanup()); + }, + }; + } catch (error) { + await cleanupWithin(2_000, () => client.close()); + await cleanupWithin(2_000, () => repo.cleanup()); + throw error; + } +} + +async function visiblePopoverBox( + page: Page, +): Promise<{ top: number; bottom: number; height: number }> { + const popover = page.getByTestId("composer-autocomplete-popover"); + await expect(popover).toBeVisible({ timeout: 30_000 }); + await expect + .poll( + async () => + popover.evaluate((element) => { + const style = window.getComputedStyle(element); + return Number(style.opacity); + }), + { timeout: 30_000 }, + ) + .toBeGreaterThan(0.95); + const box = await popover.boundingBox(); + if (!box) { + throw new Error("Autocomplete popover did not produce a bounding box."); + } + return { + top: box.y, + bottom: box.y + box.height, + height: box.height, + }; +} + +async function startPopoverFrameRecorder(page: Page): Promise { + await page.evaluate(() => { + const win = window as PopoverFrameRecorderWindow; + win.__composerAutocompleteFrames = []; + let active = true; + + const record = () => { + if (!active) return; + + const element = document.querySelector('[data-testid="composer-autocomplete-popover"]'); + if (element instanceof HTMLElement) { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + win.__composerAutocompleteFrames?.push({ + exists: true, + top: rect.top, + bottom: rect.bottom, + height: rect.height, + opacity: Number(style.opacity), + display: style.display, + visibility: style.visibility, + timestamp: performance.now(), + }); + } else { + win.__composerAutocompleteFrames?.push({ + exists: false, + top: 0, + bottom: 0, + height: 0, + opacity: 0, + display: "none", + visibility: "hidden", + timestamp: performance.now(), + }); + } + + window.requestAnimationFrame(record); + }; + + win.__stopComposerAutocompleteFrameRecorder = () => { + active = false; + }; + window.requestAnimationFrame(record); + }); +} + +async function stopPopoverFrameRecorder(page: Page): Promise { + return page.evaluate(() => { + const win = window as PopoverFrameRecorderWindow; + win.__stopComposerAutocompleteFrameRecorder?.(); + return win.__composerAutocompleteFrames ?? []; + }); +} + +function visiblePopoverFrames(frames: PopoverFrame[]): PopoverFrame[] { + return frames.filter( + (frame) => + frame.exists && + frame.height > 0 && + frame.opacity > 0.95 && + frame.display !== "none" && + frame.visibility !== "hidden", + ); +} + +function formatFrame(frame: PopoverFrame | undefined): string { + if (!frame) return "none"; + return JSON.stringify({ + top: Math.round(frame.top), + bottom: Math.round(frame.bottom), + height: Math.round(frame.height), + opacity: frame.opacity, + }); +} + +function expectPopoverFramesStable(frames: PopoverFrame[]): void { + const visibleFrames = visiblePopoverFrames(frames); + expect(visibleFrames.length).toBeGreaterThan(0); + + const finalFrame = visibleFrames[visibleFrames.length - 1]; + const jumpingFrame = visibleFrames.find( + (frame) => + Math.abs(frame.top - finalFrame.top) > 4 || + Math.abs(frame.bottom - finalFrame.bottom) > 4 || + Math.abs(frame.height - finalFrame.height) > 4, + ); + + expect( + jumpingFrame, + `expected first visible popover paint to be stable; first=${formatFrame( + visibleFrames[0], + )} jumping=${formatFrame(jumpingFrame)} final=${formatFrame(finalFrame)}`, + ).toBeUndefined(); +} + +function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): void { + const firstVisibleIndex = frames.findIndex( + (frame) => + frame.exists && + frame.height > 0 && + frame.opacity > 0.95 && + frame.display !== "none" && + frame.visibility !== "hidden", + ); + expect(firstVisibleIndex).toBeGreaterThanOrEqual(0); + + const hiddenFrame = frames + .slice(firstVisibleIndex) + .find((frame) => !frame.exists || frame.opacity < 0.95); + expect( + hiddenFrame, + `expected mounted popover to stay visible while filtering; hidden=${formatFrame(hiddenFrame)}`, + ).toBeUndefined(); +} + +test.describe("Composer autocomplete", () => { + test("does not flash at the wrong position on the first slash command paint", async ({ + page, + }) => { + await installListCommandsStub(page); + const agent = await openReadyMockAgent(page); + + try { + const input = composerLocator(page); + await expect(input).toBeEditable({ timeout: 30_000 }); + await input.click(); + + await startPopoverFrameRecorder(page); + await page.keyboard.type("/"); + await expect(page.getByText("/help", { exact: true })).toBeVisible({ timeout: 30_000 }); + await page.waitForTimeout(250); + const frames = await stopPopoverFrameRecorder(page); + + expectPopoverFramesStable(frames); + } finally { + await agent.cleanup(); + } + }); + + test("does not jump when deleting a slash command search", async ({ page }) => { + await installListCommandsStub(page); + const agent = await openReadyMockAgent(page); + + try { + const input = composerLocator(page); + await expect(input).toBeEditable({ timeout: 30_000 }); + + await input.fill("/he"); + const popover = page.getByTestId("composer-autocomplete-popover"); + await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + const beforeDelete = await visiblePopoverBox(page); + + await input.press("Backspace"); + await expect(input).toHaveValue("/h"); + await expect(popover.getByText("/history", { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + const afterDelete = await visiblePopoverBox(page); + + expect(Math.abs(afterDelete.bottom - beforeDelete.bottom)).toBeLessThanOrEqual(4); + expect(afterDelete.height).toBeGreaterThan(beforeDelete.height); + } finally { + await agent.cleanup(); + } + }); + + test("shrinks to filtered slash command results without moving the bottom edge", async ({ + page, + }) => { + await installListCommandsStub(page); + const agent = await openReadyMockAgent(page); + + try { + const input = composerLocator(page); + await expect(input).toBeEditable({ timeout: 30_000 }); + + await input.fill("/"); + const popover = page.getByTestId("composer-autocomplete-popover"); + await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + const allCommands = await visiblePopoverBox(page); + + await input.fill("/tdd"); + await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + const oneCommand = await visiblePopoverBox(page); + + expect(Math.abs(oneCommand.bottom - allCommands.bottom)).toBeLessThanOrEqual(4); + expect(oneCommand.height).toBeLessThan(allCommands.height - 40); + } finally { + await agent.cleanup(); + } + }); + + test("stays visible while filtering slash command results", async ({ page }) => { + await installListCommandsStub(page); + const agent = await openReadyMockAgent(page); + + try { + const input = composerLocator(page); + await expect(input).toBeEditable({ timeout: 30_000 }); + + await input.fill("/"); + const popover = page.getByTestId("composer-autocomplete-popover"); + await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + + await startPopoverFrameRecorder(page); + await page.keyboard.type("tdd", { delay: 40 }); + await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(250); + const frames = await stopPopoverFrameRecorder(page); + + expectPopoverDoesNotDisappearAfterFirstVisible(frames); + } finally { + await agent.cleanup(); + } + }); +}); diff --git a/packages/app/src/components/ui/autocomplete-popover.tsx b/packages/app/src/components/ui/autocomplete-popover.tsx index abca645e5..56828de06 100644 --- a/packages/app/src/components/ui/autocomplete-popover.tsx +++ b/packages/app/src/components/ui/autocomplete-popover.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; -import { Keyboard, Platform, StatusBar, View, type LayoutChangeEvent } from "react-native"; +import { useEffect, useMemo, useState, type ReactElement } from "react"; +import { Keyboard, Platform, StatusBar, View, useWindowDimensions } from "react-native"; import { Portal } from "@gorhom/portal"; import Animated, { useAnimatedStyle, @@ -54,7 +54,7 @@ export function AutocompletePopover({ emptyText, }: AutocompletePopoverProps): ReactElement | null { const [anchorRect, setAnchorRect] = useState(null); - const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); + const { height: windowHeight } = useWindowDimensions(); const insets = useSafeAreaInsets(); const { height: rawKeyboardHeight } = useReanimatedKeyboardAnimation(); @@ -76,7 +76,6 @@ export function AutocompletePopover({ useEffect(() => { if (!visible) { setAnchorRect(null); - setContentSize(null); return; } let cancelled = false; @@ -106,34 +105,15 @@ export function AutocompletePopover({ }; }, [visible, anchorRef, openShift, shift]); - const handleLayout = useCallback((event: LayoutChangeEvent) => { - const { width, height } = event.nativeEvent.layout; - setContentSize({ width, height }); - }, []); - const baseStyle = useMemo(() => { if (!anchorRect) return null; - if (!contentSize) { - // Have the anchor, waiting on the popover's own height. Render with the - // final width so the inner Autocomplete lays out at its final size, but - // stay invisible — the first visible paint will already be at the correct - // top. Mirrors combobox.tsx `shouldHideDesktopContent`. See - // docs/floating-panels.md "the two-measurement flash". - return inlineUnistylesStyle({ - position: "absolute" as const, - top: 0, - left: anchorRect.x, - width: anchorRect.width, - opacity: 0, - }); - } return inlineUnistylesStyle({ position: "absolute" as const, - top: anchorRect.y - contentSize.height - OFFSET_FROM_ANCHOR, + bottom: windowHeight - anchorRect.y + OFFSET_FROM_ANCHOR, left: anchorRect.x, width: anchorRect.width, }); - }, [anchorRect, contentSize]); + }, [anchorRect, windowHeight]); const animatedTransformStyle = useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }], @@ -149,7 +129,7 @@ export function AutocompletePopover({ return ( - + [styles.container, { maxHeight }], [maxHeight]); if (isLoading) { @@ -238,8 +239,6 @@ export function Autocomplete({ ); } - const selectedOption = options[selectedIndex]; - return ( {selectedOption?.kind === "command" && selectedOption.description ? ( diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index cf875a526..a3381a6a2 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -168,7 +168,7 @@ function resolveAutocompleteIsLoading(args: { optionsLength: number; }): boolean { if (args.mode === "command") { - return args.isCommandsLoading; + return args.isCommandsLoading && args.optionsLength === 0; } if (args.mode === "file") { return ( @@ -251,7 +251,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut const isConnected = useHostRuntimeIsConnected(serverId); const mode = resolveAutocompleteMode({ showFileAutocomplete, showCommandAutocomplete }); - const isVisible = resolveAutocompleteIsVisible({ + const canShowAutocomplete = resolveAutocompleteIsVisible({ mode, canLoadCommands, serverId, @@ -270,6 +270,8 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut draftConfig: queryDraftConfig, }); + const isVisible = canShowAutocomplete && !(mode === "command" && isCommandsLoading); + const fileSuggestionsQuery = useQuery({ queryKey: [ "directorySuggestions",