Fix mobile autocomplete sidebar layering

This commit is contained in:
Mohamed Boudra
2026-05-26 10:02:27 +07:00
parent 91b05b4111
commit 22e014aaff
6 changed files with 233 additions and 119 deletions
+15 -6
View File
@@ -40,10 +40,12 @@ Two escape hatches in the codebase:
(it has its own input) and tooltip (no input). **Not** fine for autocomplete (it has its own input) and tooltip (no input). **Not** fine for autocomplete
(the composer's input must stay focused so the user keeps typing). (the composer's input must stay focused so the user keeps typing).
- **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) — - **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) —
hoists the React subtree to a fixed mount point (`PortalHost name="root"` in hoists the React subtree to a fixed mount point whose bounds cover the
`app/_layout.tsx`) whose bounds cover the screen. Same window, same IME, screen. Same window, same IME, hit-test works because the new parent is
hit-test works because the new parent is full-screen. This is the right full-screen. This is the right default when you must keep IME attachment.
default when you must keep IME attachment. Choose the host by layer: app-global overlays use the root host; composer
autocomplete uses the content floating-panel host so sliding sidebars cover
it.
Choose Modal vs Portal by whether you need the underlying input to keep its Choose Modal vs Portal by whether you need the underlying input to keep its
keyboard. keyboard.
@@ -63,8 +65,15 @@ quietly relying on:
- **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with - **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with
`translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat `translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat
content has the same transform applied (`agent-panel.tsx:939`). They move content has the same transform applied (`agent-panel.tsx:939`). They move
together because they share the SharedValue. A portal'd popover is at the together because they share the SharedValue. A portal'd popover is outside
app root — it does not get that transform unless you apply it yourself. the composer tree — it does not get that transform unless you apply it
yourself.
- **Layering.** The default root host renders after app content, so it sits
above compact sidebars. Autocomplete should use
`useFloatingPanelPortalHostName()` and render through the current
`FloatingPanelPortalHost` instead. The app shell provides a default content
host; workspace screens provide a per-workspace host between the center pane
and explorer sidebar so both compact sidebars cover autocomplete.
The fix for transforms is Gotcha 3. The fix for transforms is Gotcha 3.
+53 -2
View File
@@ -89,6 +89,16 @@ interface PopoverFrameRecorderWindow extends Window {
__stopComposerAutocompleteFrameRecorder?: () => void; __stopComposerAutocompleteFrameRecorder?: () => void;
} }
async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
return page.evaluate(
([pointX, pointY]) => {
const element = document.elementFromPoint(pointX, pointY);
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
},
[x, y],
);
}
function getServerId(): string { function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID; const serverId = process.env.E2E_SERVER_ID;
if (!serverId) { if (!serverId) {
@@ -165,7 +175,10 @@ async function cleanupWithin(timeoutMs: number, cleanup: () => Promise<void>): P
await Promise.race([operation, new Promise<void>((resolve) => setTimeout(resolve, timeoutMs))]); await Promise.race([operation, new Promise<void>((resolve) => setTimeout(resolve, timeoutMs))]);
} }
async function openReadyMockAgent(page: Page): Promise<{ async function openReadyMockAgent(
page: Page,
options?: { expectWorkspaceTab?: boolean },
): Promise<{
cleanup: () => Promise<void>; cleanup: () => Promise<void>;
}> { }> {
const repo = await createTempGitRepo("autocomplete-popover-"); const repo = await createTempGitRepo("autocomplete-popover-");
@@ -189,7 +202,9 @@ async function openReadyMockAgent(page: Page): Promise<{
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 }, { timeout: 60_000 },
); );
await expectWorkspaceTabVisible(page, agent.id); if (options?.expectWorkspaceTab !== false) {
await expectWorkspaceTabVisible(page, agent.id);
}
await expectComposerVisible(page); await expectComposerVisible(page);
return { return {
cleanup: async () => { cleanup: async () => {
@@ -455,4 +470,40 @@ test.describe("Composer autocomplete", () => {
await agent.cleanup(); await agent.cleanup();
} }
}); });
test.describe("compact sidebar layering", () => {
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
test("keeps the mobile agent sidebar above autocomplete", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page, { expectWorkspaceTab: false });
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 page.getByRole("button", { name: "Open menu" }).click();
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
const popoverBox = await popover.boundingBox();
expect(popoverBox).not.toBeNull();
const topTestId = await getTopTestIdAtPoint(
page,
popoverBox!.x + popoverBox!.width / 2,
popoverBox!.y + popoverBox!.height / 2,
);
expect(topTestId).not.toBe("composer-autocomplete-popover");
} finally {
await agent.cleanup();
}
});
});
}); });
+2
View File
@@ -30,6 +30,7 @@ import { LeftSidebar } from "@/components/left-sidebar";
import { ProjectPickerModal } from "@/components/project-picker-modal"; import { ProjectPickerModal } from "@/components/project-picker-modal";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber"; import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform"; import { isNative, isWeb } from "@/constants/platform";
import { import {
@@ -457,6 +458,7 @@ function AppContainer({
)} )}
<View style={flexStyle}>{children}</View> <View style={flexStyle}>{children}</View>
</View> </View>
<FloatingPanelPortalHost />
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />} {isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast /> <DownloadToast />
<RosettaCalloutSource /> <RosettaCalloutSource />
@@ -10,6 +10,7 @@ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete"; import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete";
import { useFloatingPanelPortalHostName } from "@/components/ui/floating-panel-portal";
import { SPACING } from "@/styles/theme"; import { SPACING } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
@@ -56,6 +57,7 @@ export function AutocompletePopover({
const [anchorRect, setAnchorRect] = useState<Rect | null>(null); const [anchorRect, setAnchorRect] = useState<Rect | null>(null);
const { height: windowHeight } = useWindowDimensions(); const { height: windowHeight } = useWindowDimensions();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const portalHostName = useFloatingPanelPortalHostName();
const { height: rawKeyboardHeight } = useReanimatedKeyboardAnimation(); const { height: rawKeyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInsetSV = useSharedValue(insets.bottom); const bottomInsetSV = useSharedValue(insets.bottom);
@@ -127,7 +129,7 @@ export function AutocompletePopover({
if (!visible || !anchorRect || !baseStyle) return null; if (!visible || !anchorRect || !baseStyle) return null;
return ( return (
<Portal> <Portal hostName={portalHostName}>
<View style={styles.overlay} pointerEvents="box-none"> <View style={styles.overlay} pointerEvents="box-none">
<Animated.View testID="composer-autocomplete-popover" style={composedStyle}> <Animated.View testID="composer-autocomplete-popover" style={composedStyle}>
<Autocomplete <Autocomplete
@@ -0,0 +1,32 @@
import { createContext, type ReactElement, type ReactNode, useContext } from "react";
import { PortalHost } from "@gorhom/portal";
export const DEFAULT_FLOATING_PANEL_PORTAL_HOST = "content-floating-panels";
const FloatingPanelPortalHostNameContext = createContext(DEFAULT_FLOATING_PANEL_PORTAL_HOST);
export function FloatingPanelPortalHostNameProvider({
hostName,
children,
}: {
hostName: string;
children: ReactNode;
}): ReactElement {
return (
<FloatingPanelPortalHostNameContext.Provider value={hostName}>
{children}
</FloatingPanelPortalHostNameContext.Provider>
);
}
export function useFloatingPanelPortalHostName(): string {
return useContext(FloatingPanelPortalHostNameContext);
}
export function FloatingPanelPortalHost({
name = DEFAULT_FLOATING_PANEL_PORTAL_HOST,
}: {
name?: string;
}): ReactElement {
return <PortalHost name={name} />;
}
@@ -53,6 +53,10 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
FloatingPanelPortalHost,
FloatingPanelPortalHostNameProvider,
} from "@/components/ui/floating-panel-portal";
import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { SplitContainer } from "@/components/split-container"; import { SplitContainer } from "@/components/split-container";
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon"; import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
@@ -171,6 +175,7 @@ import {
} from "@/workspace/file-open"; } from "@/workspace/file-open";
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000; const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
const EMPTY_UI_TABS: WorkspaceTab[] = []; const EMPTY_UI_TABS: WorkspaceTab[] = [];
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = []; const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
const EMPTY_PINNED_AGENT_IDS = new Set<string>(); const EMPTY_PINNED_AGENT_IDS = new Set<string>();
@@ -3179,6 +3184,11 @@ function WorkspaceScreenContent({
() => isFocusModeEnabled && !isMobile, () => isFocusModeEnabled && !isMobile,
[isFocusModeEnabled, isMobile], [isFocusModeEnabled, isMobile],
); );
const workspaceFloatingPanelPortalHostName = useMemo(
() =>
`${WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX}:${normalizedServerId}:${normalizedWorkspaceId}`,
[normalizedServerId, normalizedWorkspaceId],
);
const desktopContent = useMemo(() => { const desktopContent = useMemo(() => {
if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) { if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) {
return content; return content;
@@ -3254,6 +3264,120 @@ function WorkspaceScreenContent({
renderSplitPaneEmptyState, renderSplitPaneEmptyState,
]); ]);
const workspaceCenterColumn = (
<View style={styles.centerColumn}>
{showScreenHeader && (
<ScreenHeader
onRowLayout={onHeaderLayout}
left={
<>
<SidebarMenuToggle />
<WorkspaceHeaderTitleBar
isLoading={isWorkspaceHeaderLoading}
title={workspaceHeaderTitle}
subtitle={workspaceHeaderSubtitle}
showSubtitle={shouldShowWorkspaceHeaderSubtitle}
currentBranchName={currentBranchName}
isGitCheckout={isGitCheckout}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
workspaceScripts={workspaceScripts}
liveTerminalIds={liveTerminalIds}
showWorkspaceSetup={showWorkspaceSetup}
showCreateBrowserTab={showCreateBrowserTab}
isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled}
importAgentDisabled={!canOpenImportSheet}
menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon}
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
menuImportIcon={MENU_IMPORT_ICON}
menuCopyIcon={menuCopyIcon}
menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal}
onCreateBrowser={handleCreateBrowserTab}
onOpenImportSheet={openImportSheet}
onCopyWorkspacePath={handleCopyWorkspacePath}
onCopyBranchName={handleCopyBranchName}
onOpenSetupTab={handleOpenSetupTab}
onScriptTerminalStarted={handleScriptTerminalStarted}
onViewScriptTerminal={handleViewScriptTerminal}
onOpenUrlInBrowserTab={handleOpenUrlInBrowserTab}
/>
</>
}
right={headerRight}
/>
)}
{isMobile ? (
<MobileWorkspaceTabSwitcher
tabs={tabs}
activeTabKey={activeTabKey}
activeTab={activeTabDescriptor}
tabSwitcherOptions={tabSwitcherOptions}
tabByKey={tabByKey}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onSelectSwitcherTab={handleSelectSwitcherTab}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTab={handleCloseTabById}
onCloseTabsAbove={handleCloseTabsToLeft}
onCloseTabsBelow={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
/>
) : null}
{shouldRenderDesktopPaneFallback ? (
<WorkspaceDesktopTabsRow
paneId={focusedPaneIdOrUndefined}
isFocused={isRouteFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={navigateToTabId}
onCloseTab={handleCloseTabById}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminalTab={handleCreateTerminal}
onCreateBrowserTab={handleCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
disableCreateTerminal={createTerminalMutation.isPending}
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
onReorderTabs={handleReorderTabsInFocusedPane}
onSplitRight={noop}
onSplitDown={noop}
showPaneSplitActions={false}
/>
) : null}
<View style={styles.centerContent}>
{isMobile ? (
<GestureDetector
gesture={explorerOpenGesture}
touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}
>
<View style={styles.content}>{content}</View>
</GestureDetector>
) : (
<View style={styles.content}>{desktopContent}</View>
)}
</View>
</View>
);
return ( return (
gatedWorkspaceScreen ?? ( gatedWorkspaceScreen ?? (
<WorkspaceFocusProvider workspaceKey={persistenceKey}> <WorkspaceFocusProvider workspaceKey={persistenceKey}>
@@ -3265,117 +3389,11 @@ function WorkspaceScreenContent({
isRouteFocused={isRouteFocused} isRouteFocused={isRouteFocused}
/> />
<View style={styles.threePaneRow}> <View style={styles.threePaneRow}>
<View style={styles.centerColumn}> <FloatingPanelPortalHostNameProvider hostName={workspaceFloatingPanelPortalHostName}>
{showScreenHeader && ( {workspaceCenterColumn}
<ScreenHeader </FloatingPanelPortalHostNameProvider>
onRowLayout={onHeaderLayout}
left={
<>
<SidebarMenuToggle />
<WorkspaceHeaderTitleBar
isLoading={isWorkspaceHeaderLoading}
title={workspaceHeaderTitle}
subtitle={workspaceHeaderSubtitle}
showSubtitle={shouldShowWorkspaceHeaderSubtitle}
currentBranchName={currentBranchName}
isGitCheckout={isGitCheckout}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
workspaceScripts={workspaceScripts}
liveTerminalIds={liveTerminalIds}
showWorkspaceSetup={showWorkspaceSetup}
showCreateBrowserTab={showCreateBrowserTab}
isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled}
importAgentDisabled={!canOpenImportSheet}
menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon}
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
menuImportIcon={MENU_IMPORT_ICON}
menuCopyIcon={menuCopyIcon}
menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal}
onCreateBrowser={handleCreateBrowserTab}
onOpenImportSheet={openImportSheet}
onCopyWorkspacePath={handleCopyWorkspacePath}
onCopyBranchName={handleCopyBranchName}
onOpenSetupTab={handleOpenSetupTab}
onScriptTerminalStarted={handleScriptTerminalStarted}
onViewScriptTerminal={handleViewScriptTerminal}
onOpenUrlInBrowserTab={handleOpenUrlInBrowserTab}
/>
</>
}
right={headerRight}
/>
)}
{isMobile ? ( <FloatingPanelPortalHost name={workspaceFloatingPanelPortalHostName} />
<MobileWorkspaceTabSwitcher
tabs={tabs}
activeTabKey={activeTabKey}
activeTab={activeTabDescriptor}
tabSwitcherOptions={tabSwitcherOptions}
tabByKey={tabByKey}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onSelectSwitcherTab={handleSelectSwitcherTab}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTab={handleCloseTabById}
onCloseTabsAbove={handleCloseTabsToLeft}
onCloseTabsBelow={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
/>
) : null}
{shouldRenderDesktopPaneFallback ? (
<WorkspaceDesktopTabsRow
paneId={focusedPaneIdOrUndefined}
isFocused={isRouteFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={navigateToTabId}
onCloseTab={handleCloseTabById}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminalTab={handleCreateTerminal}
onCreateBrowserTab={handleCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
disableCreateTerminal={createTerminalMutation.isPending}
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
onReorderTabs={handleReorderTabsInFocusedPane}
onSplitRight={noop}
onSplitDown={noop}
showPaneSplitActions={false}
/>
) : null}
<View style={styles.centerContent}>
{isMobile ? (
<GestureDetector
gesture={explorerOpenGesture}
touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}
>
<View style={styles.content}>{content}</View>
</GestureDetector>
) : (
<View style={styles.content}>{desktopContent}</View>
)}
</View>
</View>
{showExplorerSidebar && workspaceDirectory ? ( {showExplorerSidebar && workspaceDirectory ? (
<ExplorerSidebar <ExplorerSidebar