diff --git a/packages/app/e2e/workspace-scripts-menu-resize.spec.ts b/packages/app/e2e/workspace-scripts-menu-resize.spec.ts new file mode 100644 index 000000000..dec22a14c --- /dev/null +++ b/packages/app/e2e/workspace-scripts-menu-resize.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from "./fixtures"; +import { createTempGitRepo } from "./helpers/workspace"; +import { + connectWorkspaceSetupClient, + createWorkspaceThroughDaemon, + openWorkspaceScriptsMenu, + seedProjectForWorkspaceSetup, + startWorkspaceScriptFromMenu, + waitForWorkspaceSetupProgress, +} from "./helpers/workspace-setup"; +import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { getServerId } from "./helpers/server-id"; +import { buildHostWorkspaceRoute } from "../src/utils/host-routes"; + +test("scripts menu resizes when a service row grows after launch", async ({ page }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("script-menu-resize-", { + paseoConfig: { + worktree: { + setup: ["sh -c 'echo setup complete'"], + }, + scripts: { + web: { + type: "service", + command: + "node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"", + }, + }, + }, + }); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + const completed = waitForWorkspaceSetupProgress( + client, + (payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), + ); + const workspace = await createWorkspaceThroughDaemon(client, { + cwd: repo.path, + worktreeSlug: `script-menu-resize-${Date.now()}`, + }); + await completed; + + await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id)); + await waitForWorkspaceTabsVisible(page); + await openWorkspaceScriptsMenu(page); + + const menu = page.getByTestId("workspace-scripts-menu"); + const before = await menu.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + height: rect.height, + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + }; + }); + + await startWorkspaceScriptFromMenu(page, "web"); + await expect(menu).toContainText("localhost:", { timeout: 15_000 }); + + const after = await menu.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const firstChild = element.firstElementChild; + const childRect = firstChild?.getBoundingClientRect(); + return { + height: rect.height, + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + childHeight: childRect?.height ?? 0, + }; + }); + + expect(after.height).toBeGreaterThan(before.height); + expect(after.scrollHeight).toBeLessThanOrEqual(after.clientHeight + 1); + expect(after.childHeight).toBeGreaterThan(before.height); + } finally { + await client.close(); + await repo.cleanup(); + } +}); diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index a51f92282..a50ea04fc 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -3,6 +3,7 @@ import { useCallback, useContext, useEffect, + useId, useMemo, useRef, useState, @@ -30,6 +31,7 @@ import { Check, CheckCircle } from "lucide-react-native"; import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; +import { isWeb } from "@/constants/platform"; // Action status for menu items with loading/success feedback export type ActionStatus = "idle" | "pending" | "success"; @@ -178,10 +180,19 @@ function renderDropdownSurface(input: { scrollable: boolean; scrollViewportStyle: StyleProp; content: ReactElement; + surfaceNativeID: string; onExited: () => void; }): ReactElement { - const { frameStyle, testID, surfaceStyle, scrollable, scrollViewportStyle, content, onExited } = - input; + const { + frameStyle, + testID, + surfaceStyle, + scrollable, + scrollViewportStyle, + content, + surfaceNativeID, + onExited, + } = input; const body = scrollable ? ( { + if (!enabled) return undefined; + + // Reanimated web entering animations leave the measured menu surface with + // an inline height snapshot. Once the menu is open, height must return to + // content-sized so rows can grow in place (for example service script URLs). + const release = () => { + releaseFixedMenuHeight(surfaceNativeID); + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => releaseFixedMenuHeight(surfaceNativeID)); + } + }; + const timers: ReturnType[] = [ + setTimeout(release, CONTENT_ENTERING_DURATION_MS), + ]; + + if (contentSize) { + timers.push(setTimeout(release, 0)); + } + + return () => { + for (const timer of timers) clearTimeout(timer); + }; + }, [contentSize, enabled, surfaceNativeID]); +} + export function DropdownMenuContent({ children, side = "bottom", @@ -389,6 +443,7 @@ export function DropdownMenuContent({ const { open, setOpen, triggerRef, flushPendingSelect } = useDropdownMenuContext("DropdownMenuContent"); const [modalVisible, setModalVisible] = useState(false); + const surfaceNativeID = useId(); const webScrollbarStyle = useWebScrollbarStyle(); const [closing, setClosing] = useState(false); const [triggerRect, setTriggerRect] = useState(null); @@ -429,6 +484,12 @@ export function DropdownMenuContent({ } }, [flushPendingSelect, modalVisible, open]); + useReleaseFixedMenuHeight({ + contentSize, + enabled: modalVisible, + surfaceNativeID, + }); + const handleClose = useCallback(() => { setOpen(false); }, [setOpen]); @@ -575,6 +636,7 @@ export function DropdownMenuContent({ scrollable, scrollViewportStyle, content, + surfaceNativeID, onExited: () => setModalVisible(false), }) : null}