Fix autocomplete popover flicker
This commit is contained in:
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void>): Promise<void> {
|
||||||
|
const operation = cleanup().catch(() => undefined);
|
||||||
|
await Promise.race([operation, new Promise<void>((resolve) => setTimeout(resolve, timeoutMs))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openReadyMockAgent(page: Page): Promise<{
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
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<void> {
|
||||||
|
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<PopoverFrame[]> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
import { useEffect, useMemo, useState, type ReactElement } from "react";
|
||||||
import { Keyboard, Platform, StatusBar, View, type LayoutChangeEvent } from "react-native";
|
import { Keyboard, Platform, StatusBar, View, useWindowDimensions } from "react-native";
|
||||||
import { Portal } from "@gorhom/portal";
|
import { Portal } from "@gorhom/portal";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
@@ -54,7 +54,7 @@ export function AutocompletePopover({
|
|||||||
emptyText,
|
emptyText,
|
||||||
}: AutocompletePopoverProps): ReactElement | null {
|
}: AutocompletePopoverProps): ReactElement | null {
|
||||||
const [anchorRect, setAnchorRect] = useState<Rect | null>(null);
|
const [anchorRect, setAnchorRect] = useState<Rect | null>(null);
|
||||||
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
const { height: windowHeight } = useWindowDimensions();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { height: rawKeyboardHeight } = useReanimatedKeyboardAnimation();
|
const { height: rawKeyboardHeight } = useReanimatedKeyboardAnimation();
|
||||||
@@ -76,7 +76,6 @@ export function AutocompletePopover({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
setAnchorRect(null);
|
setAnchorRect(null);
|
||||||
setContentSize(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -106,34 +105,15 @@ export function AutocompletePopover({
|
|||||||
};
|
};
|
||||||
}, [visible, anchorRef, openShift, shift]);
|
}, [visible, anchorRef, openShift, shift]);
|
||||||
|
|
||||||
const handleLayout = useCallback((event: LayoutChangeEvent) => {
|
|
||||||
const { width, height } = event.nativeEvent.layout;
|
|
||||||
setContentSize({ width, height });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const baseStyle = useMemo(() => {
|
const baseStyle = useMemo(() => {
|
||||||
if (!anchorRect) return null;
|
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({
|
return inlineUnistylesStyle({
|
||||||
position: "absolute" as const,
|
position: "absolute" as const,
|
||||||
top: anchorRect.y - contentSize.height - OFFSET_FROM_ANCHOR,
|
bottom: windowHeight - anchorRect.y + OFFSET_FROM_ANCHOR,
|
||||||
left: anchorRect.x,
|
left: anchorRect.x,
|
||||||
width: anchorRect.width,
|
width: anchorRect.width,
|
||||||
});
|
});
|
||||||
}, [anchorRect, contentSize]);
|
}, [anchorRect, windowHeight]);
|
||||||
|
|
||||||
const animatedTransformStyle = useAnimatedStyle(() => ({
|
const animatedTransformStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{ translateY: openShift.value - shift.value }],
|
transform: [{ translateY: openShift.value - shift.value }],
|
||||||
@@ -149,7 +129,7 @@ export function AutocompletePopover({
|
|||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
<View style={styles.overlay} pointerEvents="box-none">
|
<View style={styles.overlay} pointerEvents="box-none">
|
||||||
<Animated.View style={composedStyle} onLayout={handleLayout}>
|
<Animated.View testID="composer-autocomplete-popover" style={composedStyle}>
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
options={options}
|
options={options}
|
||||||
selectedIndex={selectedIndex}
|
selectedIndex={selectedIndex}
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ export function Autocomplete({
|
|||||||
[ensureActiveItemVisible],
|
[ensureActiveItemVisible],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const selectedOption = options[selectedIndex];
|
||||||
const containerStyle = useMemo(() => [styles.container, { maxHeight }], [maxHeight]);
|
const containerStyle = useMemo(() => [styles.container, { maxHeight }], [maxHeight]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -238,8 +239,6 @@ export function Autocomplete({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedOption = options[selectedIndex];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.outerWrapper}>
|
<View style={styles.outerWrapper}>
|
||||||
{selectedOption?.kind === "command" && selectedOption.description ? (
|
{selectedOption?.kind === "command" && selectedOption.description ? (
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ function resolveAutocompleteIsLoading(args: {
|
|||||||
optionsLength: number;
|
optionsLength: number;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (args.mode === "command") {
|
if (args.mode === "command") {
|
||||||
return args.isCommandsLoading;
|
return args.isCommandsLoading && args.optionsLength === 0;
|
||||||
}
|
}
|
||||||
if (args.mode === "file") {
|
if (args.mode === "file") {
|
||||||
return (
|
return (
|
||||||
@@ -251,7 +251,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
|||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
|
||||||
const mode = resolveAutocompleteMode({ showFileAutocomplete, showCommandAutocomplete });
|
const mode = resolveAutocompleteMode({ showFileAutocomplete, showCommandAutocomplete });
|
||||||
const isVisible = resolveAutocompleteIsVisible({
|
const canShowAutocomplete = resolveAutocompleteIsVisible({
|
||||||
mode,
|
mode,
|
||||||
canLoadCommands,
|
canLoadCommands,
|
||||||
serverId,
|
serverId,
|
||||||
@@ -270,6 +270,8 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
|||||||
draftConfig: queryDraftConfig,
|
draftConfig: queryDraftConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isVisible = canShowAutocomplete && !(mode === "command" && isCommandsLoading);
|
||||||
|
|
||||||
const fileSuggestionsQuery = useQuery({
|
const fileSuggestionsQuery = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"directorySuggestions",
|
"directorySuggestions",
|
||||||
|
|||||||
Reference in New Issue
Block a user