Fix scripts menu resizing after service launch

Reanimated entering animations can leave the measured menu surface with an inline height. Release that fixed height after opening and when measured content changes so service rows can grow in place.
This commit is contained in:
Mohamed Boudra
2026-05-31 16:38:01 +07:00
parent a1c8e1a1f9
commit 794cbcef63
2 changed files with 145 additions and 3 deletions

View File

@@ -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();
}
});

View File

@@ -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<ViewStyle>;
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 ? (
<FloatingScrollView
@@ -199,6 +210,7 @@ function renderDropdownSurface(input: {
return (
<FloatingSurface
collapsable={false}
nativeID={surfaceNativeID}
testID={testID}
style={surfaceStyle}
frameStyle={frameStyle}
@@ -350,16 +362,58 @@ function getTransformOrigin(placement: Placement, alignment: Alignment): string
return `${vertical} ${horizontal}`;
}
const CONTENT_ENTERING_DURATION_MS = 150;
const contentEntering = new Keyframe({
0: { opacity: 0, transform: [{ scale: 0.97 }] },
100: { opacity: 1, transform: [{ scale: 1 }] },
}).duration(150);
}).duration(CONTENT_ENTERING_DURATION_MS);
const contentExiting = new Keyframe({
0: { opacity: 1, transform: [{ scale: 1 }] },
100: { opacity: 0, transform: [{ scale: 0.97 }] },
}).duration(100);
function releaseFixedMenuHeight(surfaceNativeID: string): void {
if (!isWeb) return;
document.getElementById(surfaceNativeID)?.style.removeProperty("height");
}
function useReleaseFixedMenuHeight({
contentSize,
enabled,
surfaceNativeID,
}: {
contentSize: Size | null;
enabled: boolean;
surfaceNativeID: string;
}): void {
useEffect(() => {
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<typeof setTimeout>[] = [
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<Rect | null>(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}