diff --git a/docs/floating-panels.md b/docs/floating-panels.md index f6329cd32..a705603d2 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -40,10 +40,12 @@ Two escape hatches in the codebase: (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). - **`` from `@gorhom/portal`** (hover-card, autocomplete-popover) — - hoists the React subtree to a fixed mount point (`PortalHost name="root"` in - `app/_layout.tsx`) whose bounds cover the screen. Same window, same IME, - hit-test works because the new parent is full-screen. This is the right - default when you must keep IME attachment. + hoists the React subtree to a fixed mount point whose bounds cover the + screen. Same window, same IME, hit-test works because the new parent is + full-screen. This is the right 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 keyboard. @@ -63,8 +65,15 @@ quietly relying on: - **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with `translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat 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 - app root — it does not get that transform unless you apply it yourself. + together because they share the SharedValue. A portal'd popover is outside + 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. diff --git a/packages/app/e2e/composer-autocomplete.spec.ts b/packages/app/e2e/composer-autocomplete.spec.ts index 7ebaf6e26..c02372905 100644 --- a/packages/app/e2e/composer-autocomplete.spec.ts +++ b/packages/app/e2e/composer-autocomplete.spec.ts @@ -89,6 +89,16 @@ interface PopoverFrameRecorderWindow extends Window { __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 { const serverId = process.env.E2E_SERVER_ID; if (!serverId) { @@ -165,7 +175,10 @@ async function cleanupWithin(timeoutMs: number, cleanup: () => Promise): P await Promise.race([operation, new Promise((resolve) => setTimeout(resolve, timeoutMs))]); } -async function openReadyMockAgent(page: Page): Promise<{ +async function openReadyMockAgent( + page: Page, + options?: { expectWorkspaceTab?: boolean }, +): Promise<{ cleanup: () => Promise; }> { 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"), { timeout: 60_000 }, ); - await expectWorkspaceTabVisible(page, agent.id); + if (options?.expectWorkspaceTab !== false) { + await expectWorkspaceTabVisible(page, agent.id); + } await expectComposerVisible(page); return { cleanup: async () => { @@ -455,4 +470,40 @@ test.describe("Composer autocomplete", () => { 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(); + } + }); + }); }); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index efc5c2844..e7e8f9d66 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -30,6 +30,7 @@ import { LeftSidebar } from "@/components/left-sidebar"; import { ProjectPickerModal } from "@/components/project-picker-modal"; import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber"; +import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal"; import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; import { @@ -457,6 +458,7 @@ function AppContainer({ )} {children} + {isCompactLayout && chromeEnabled && } diff --git a/packages/app/src/components/ui/autocomplete-popover.tsx b/packages/app/src/components/ui/autocomplete-popover.tsx index 56828de06..96bbeb23f 100644 --- a/packages/app/src/components/ui/autocomplete-popover.tsx +++ b/packages/app/src/components/ui/autocomplete-popover.tsx @@ -10,6 +10,7 @@ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet } from "react-native-unistyles"; import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete"; +import { useFloatingPanelPortalHostName } from "@/components/ui/floating-panel-portal"; import { SPACING } from "@/styles/theme"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; @@ -56,6 +57,7 @@ export function AutocompletePopover({ const [anchorRect, setAnchorRect] = useState(null); const { height: windowHeight } = useWindowDimensions(); const insets = useSafeAreaInsets(); + const portalHostName = useFloatingPanelPortalHostName(); const { height: rawKeyboardHeight } = useReanimatedKeyboardAnimation(); const bottomInsetSV = useSharedValue(insets.bottom); @@ -127,7 +129,7 @@ export function AutocompletePopover({ if (!visible || !anchorRect || !baseStyle) return null; return ( - + + {children} + + ); +} + +export function useFloatingPanelPortalHostName(): string { + return useContext(FloatingPanelPortalHostNameContext); +} + +export function FloatingPanelPortalHost({ + name = DEFAULT_FLOATING_PANEL_PORTAL_HOST, +}: { + name?: string; +}): ReactElement { + return ; +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 4c2088da5..aa6ca9efa 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -53,6 +53,10 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + FloatingPanelPortalHost, + FloatingPanelPortalHostNameProvider, +} from "@/components/ui/floating-panel-portal"; import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { SplitContainer } from "@/components/split-container"; import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon"; @@ -171,6 +175,7 @@ import { } from "@/workspace/file-open"; 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_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = []; const EMPTY_PINNED_AGENT_IDS = new Set(); @@ -3179,6 +3184,11 @@ function WorkspaceScreenContent({ () => isFocusModeEnabled && !isMobile, [isFocusModeEnabled, isMobile], ); + const workspaceFloatingPanelPortalHostName = useMemo( + () => + `${WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX}:${normalizedServerId}:${normalizedWorkspaceId}`, + [normalizedServerId, normalizedWorkspaceId], + ); const desktopContent = useMemo(() => { if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) { return content; @@ -3254,6 +3264,120 @@ function WorkspaceScreenContent({ renderSplitPaneEmptyState, ]); + const workspaceCenterColumn = ( + + {showScreenHeader && ( + + + + + } + right={headerRight} + /> + )} + + {isMobile ? ( + + ) : null} + + {shouldRenderDesktopPaneFallback ? ( + + ) : null} + + + {isMobile ? ( + + {content} + + ) : ( + {desktopContent} + )} + + + ); + return ( gatedWorkspaceScreen ?? ( @@ -3265,117 +3389,11 @@ function WorkspaceScreenContent({ isRouteFocused={isRouteFocused} /> - - {showScreenHeader && ( - - - - - } - right={headerRight} - /> - )} + + {workspaceCenterColumn} + - {isMobile ? ( - - ) : null} - - {shouldRenderDesktopPaneFallback ? ( - - ) : null} - - - {isMobile ? ( - - {content} - - ) : ( - {desktopContent} - )} - - + {showExplorerSidebar && workspaceDirectory ? (