From ef885ddc8ba94b9f9ab72d8f228df135d895962f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 19 Oct 2026 19:00:23 +0200 Subject: [PATCH] fix(app): preserve browser interactions in retained workspace hosts Initially disabled gesture hosts still registered pointer and wheel listeners, while hover cards used window positions inside a workspace-local sheet portal. Respect initial gesture enablement and position cards relative to their measured floating host. Keep developer-toast dismissal independent of pointer input and replacement toast visibility so modal, hover, and focus checks retain their input state. --- docs/mobile-panels.md | 4 +- docs/testing.md | 2 +- .../app/e2e/browser/plugin-host-ui.spec.ts | 11 ----- .../app/e2e/browser/plugin-modal-body.spec.ts | 8 +--- .../sidebar-mobile-menu-sheets.spec.ts | 3 ++ .../app/e2e/browser/sidebar-workspace.spec.ts | 6 ++- packages/app/e2e/browser/usage-modal.spec.ts | 5 ++- packages/app/e2e/support/fixtures.ts | 8 +++- .../app/e2e/support/helpers/context-window.ts | 2 +- packages/app/src/components/ui/hover-card.tsx | 28 +++++++----- .../react-native-gesture-handler+2.32.0.patch | 45 +++++++++++++++++++ 11 files changed, 85 insertions(+), 37 deletions(-) diff --git a/docs/mobile-panels.md b/docs/mobile-panels.md index 6bd528e3e8..3301b4b60a 100644 --- a/docs/mobile-panels.md +++ b/docs/mobile-panels.md @@ -32,7 +32,9 @@ The UI worklet owns transient motion: React publishes the active panel only when the canonical target and the UI-thread position agree at the final anchor. Retained content never observes gesture previews, progress, or an unsettled target. -The gesture hosts stay mounted. Native worklets own overlay opacity and pointer events while +The gesture hosts stay mounted. The Gesture Handler web patch keeps initially disabled hosts +from capturing paragraph pointers; that capture confines browser selection to its starting paragraph. +Native worklets own overlay opacity and pointer events while React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or release. Native panel hosts and their dependent draggable lists diff --git a/docs/testing.md b/docs/testing.md index 91340ad070..6f70f6af0c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -193,7 +193,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es - Playwright specs that exercise only the daemon import `daemonTest` from the shared fixtures so they do not create a browser context or page. - Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`. - Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics. -- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay. Automated Metro launches set `EXPO_UNSTABLE_HEADLESS=1` so Expo does not download the native DevTools shell into temporary test homes; its read-only cache breaks teardown. Browser fixtures dismiss Expo's developer error toast through its close button while retaining console logs, so it cannot cover compact bottom actions. +- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay. Automated Metro launches set `EXPO_UNSTABLE_HEADLESS=1` so Expo does not download the native DevTools shell into temporary test homes; its read-only cache breaks teardown. Browser fixtures dispatch Expo's developer-toast close action while retaining console logs. A pointer click moves hover away from the tested control and stalls behind app modal backdrops. - The app Playwright harness boots on Windows as well as POSIX. Spawn Node entrypoints through `process.execPath`, not `npx` or `node_modules/.bin` shims: Node refuses to spawn `.cmd` or `.bat` without `shell: true`, and shell mode concatenates argv without escaping and sends kill signals to `cmd.exe` instead of the real child. - Teardown kills the process tree, because a Windows signal reaches only the direct child and leaves forked workers holding the listening port. - The `asdf`-backed local Elixir relay stays POSIX-only, so the `relay-deployment` Playwright project is unavailable on Windows. diff --git a/packages/app/e2e/browser/plugin-host-ui.spec.ts b/packages/app/e2e/browser/plugin-host-ui.spec.ts index 84d786033a..1d952fe92f 100644 --- a/packages/app/e2e/browser/plugin-host-ui.spec.ts +++ b/packages/app/e2e/browser/plugin-host-ui.spec.ts @@ -5,10 +5,6 @@ import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; -import { - expectMobileAgentSidebarVisible, - openMobileAgentSidebar, -} from "../support/helpers/sidebar"; const PLUGIN_ID = "plugin-host-ui-e2e"; @@ -91,12 +87,6 @@ async function useCompactLayout(page: Page): Promise { await page.setViewportSize({ width: 390, height: 844 }); } -async function reopenHostUiPluginFromCompactSidebar(page: Page): Promise { - await openMobileAgentSidebar(page); - await expectMobileAgentSidebarVisible(page); - await page.getByRole("button", { name: "Host UI", exact: true }).click(); -} - async function openPluginModal(page: Page): Promise { await page.getByRole("button", { name: "Open plugin modal", exact: true }).click(); } @@ -158,7 +148,6 @@ test("plugin modal adapts its presentation and preserves host contexts", async ( await test.step("compact layouts preserve host contexts inside a sheet", async () => { await useCompactLayout(page); - await reopenHostUiPluginFromCompactSidebar(page); await openPluginModal(page); await expectCompactPluginSheet(page); await savePluginIssue(page); diff --git a/packages/app/e2e/browser/plugin-modal-body.spec.ts b/packages/app/e2e/browser/plugin-modal-body.spec.ts index bdd822b9c8..33be8a5c40 100644 --- a/packages/app/e2e/browser/plugin-modal-body.spec.ts +++ b/packages/app/e2e/browser/plugin-modal-body.spec.ts @@ -2,10 +2,6 @@ import { copyPluginExample } from "../support/helpers/plugin-fixture"; import { expect, test as base, type Page } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; -import { - openMobileAgentSidebar, - expectMobileAgentSidebarVisible, -} from "../support/helpers/sidebar"; async function openExample(page: Page, example: string) { await page.getByRole("button", { name: `Open ${example}`, exact: true }).click(); @@ -122,9 +118,7 @@ async function openWideExamples(page: Page) { async function openCompactExamples(page: Page) { await page.setViewportSize({ width: 390, height: 844 }); - await openMobileAgentSidebar(page); - await expectMobileAgentSidebarVisible(page); - await page.getByRole("button", { name: "Modal examples", exact: true }).click(); + await expect(page.getByRole("button", { name: "Open Form", exact: true })).toBeVisible(); } async function expectDeniedCopyFeedback(page: Page) { diff --git a/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts b/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts index 7b4c36bf97..8b06e14f67 100644 --- a/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts +++ b/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts @@ -15,6 +15,7 @@ async function closeMenuSheet(page: Page): Promise { test("project and workspace kebabs open action sheets on compact layouts", async ({ page }) => { const seeded = await seedWorkspace({ repoPrefix: "sidebar-mobile-menu-sheet-" }); + const secondProject = await seedWorkspace({ repoPrefix: "sidebar-mobile-menu-filter-" }); try { await gotoAppShell(page); @@ -26,6 +27,7 @@ test("project and workspace kebabs open action sheets on compact layouts", async /^Grouping/, /^Title/, "Show", + "Project", "Show background", ]); await closeMenuSheet(page); @@ -53,6 +55,7 @@ test("project and workspace kebabs open action sheets on compact layouts", async }); await expect(page.getByText("Workspace actions", { exact: true })).toBeVisible(); } finally { + await secondProject.cleanup(); await seeded.cleanup(); } }); diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index 85de01a376..d96f68580c 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -624,7 +624,9 @@ test.describe("Half-screen desktop layout", () => { await expect(page.getByTestId("sidebar-settings")).not.toBeVisible(); }); - test("keeps app navigation beside the Explorer pane", async ({ page }) => { + test("yields app navigation to Explorer and restores it when Explorer closes", async ({ + page, + }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" }); try { @@ -636,7 +638,7 @@ test.describe("Half-screen desktop layout", () => { const explorerToggle = page.getByTestId("workspace-explorer-toggle").first(); await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible(); await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar"); - await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); + await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible(); await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible(); await expect( page diff --git a/packages/app/e2e/browser/usage-modal.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts index f51c0ad510..9a1bfa9b68 100644 --- a/packages/app/e2e/browser/usage-modal.spec.ts +++ b/packages/app/e2e/browser/usage-modal.spec.ts @@ -606,7 +606,10 @@ for (const compact of [false, true]) { if (compact) await openCompactSidebar(page); await openUsageFromIcon(page); await expectLoginErrors(page, ["Claude: Initial failure"]); - await expectLoginUsageFreshness(page, "No successful update", compact); + await test.step("developer errors do not interrupt freshness inspection", async () => { + await page.evaluate(() => console.error("E2E: developer toast while Usage is open")); + await expectLoginUsageFreshness(page, "No successful update", compact); + }); await fixture.setReport({ status: "available", diff --git a/packages/app/e2e/support/fixtures.ts b/packages/app/e2e/support/fixtures.ts index bd97ec1c9e..103387f8c5 100644 --- a/packages/app/e2e/support/fixtures.ts +++ b/packages/app/e2e/support/fixtures.ts @@ -131,11 +131,15 @@ const test = daemonTest.extend<{ }); // Expo's development LogBox toast overlaps bottom actions on compact screens. - // Dismiss its UI through the close button; console errors still go into the test log. + // Dispatch the developer UI's close action without moving the pointer or + // clicking through an app modal. Console errors still go into the test log. const dismissDevToast = page .locator("#error-toast") .getByRole("button", { name: "", exact: true }); - await page.addLocatorHandler(dismissDevToast, () => dismissDevToast.click()); + await page.addLocatorHandler(dismissDevToast, () => dismissDevToast.dispatchEvent("click"), { + // Another logged error can replace the toast before it disappears. + noWaitAfter: true, + }); const entries: string[] = []; diff --git a/packages/app/e2e/support/helpers/context-window.ts b/packages/app/e2e/support/helpers/context-window.ts index 21341c7766..afc91f859c 100644 --- a/packages/app/e2e/support/helpers/context-window.ts +++ b/packages/app/e2e/support/helpers/context-window.ts @@ -37,7 +37,7 @@ export async function hoverContextWindowMeter(page: Page, name = METER_NAME): Pr await page.getByRole("img", { name, exact: true }).hover({ timeout: 30_000 }); const card = contextWindowDetails(page); await expect(card).toBeVisible(); - await expect(card).toBeInViewport(); + await expect(card).toBeInViewport({ ratio: 1 }); return card; } diff --git a/packages/app/src/components/ui/hover-card.tsx b/packages/app/src/components/ui/hover-card.tsx index 3d438505d3..fd81294f80 100644 --- a/packages/app/src/components/ui/hover-card.tsx +++ b/packages/app/src/components/ui/hover-card.tsx @@ -14,13 +14,16 @@ import { Dimensions, View, type Role, type StyleProp, type ViewStyle } from "rea import { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet } from "react-native-unistyles"; import { Portal } from "@gorhom/portal"; -import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; import { OverlayLayerProvider, useOverlayLayer, useWebOverlayRegistration, } from "@/lib/overlay-root"; import { FloatingSurface } from "@/components/ui/floating"; +import { + measureFloatingPanelPortalHost, + useFloatingPanelPortalHostName, +} from "@/components/ui/floating-panel-portal"; import { isWeb } from "@/constants/platform"; import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone"; import { @@ -326,28 +329,31 @@ function HoverCardSurface({ triggerRef: RefObject; contentRef: RefObject; }): ReactElement { - const bottomSheetInternal = useBottomSheetModalInternal(true); - const [triggerRect, setTriggerRect] = useState(null); + const portalHostName = useFloatingPanelPortalHostName(); + const [anchor, setAnchor] = useState<{ triggerRect: Rect; hostRect: Rect } | null>(null); const [contentSize, setContentSize] = useState(null); const [position, setPosition] = useState<{ x: number; y: number } | null>(null); useEffect(() => { if (!triggerRef.current) return; let cancelled = false; - void measureElement(triggerRef.current).then((rect) => { - if (!cancelled) setTriggerRect(rect); + void Promise.all([ + measureElement(triggerRef.current), + measureFloatingPanelPortalHost(portalHostName), + ]).then(([triggerRect, hostRect]) => { + if (!cancelled && hostRect) setAnchor({ triggerRect, hostRect }); return undefined; }); return () => { cancelled = true; }; - }, [triggerRef]); + }, [portalHostName, triggerRef]); useEffect(() => { - if (!triggerRect || !contentSize) return; + if (!anchor || !contentSize) return; const { width, height } = Dimensions.get("window"); const { x, y } = computePosition({ - triggerRect, + triggerRect: anchor.triggerRect, contentSize, displayArea: { x: 0, y: 0, width, height }, placement, @@ -355,8 +361,8 @@ function HoverCardSurface({ alignment, offset, }); - setPosition({ x, y }); - }, [alignment, contentSize, offset, placement, triggerRect]); + setPosition({ x: x - anchor.hostRect.x, y: y - anchor.hostRect.y }); + }, [alignment, anchor, contentSize, offset, placement]); const handleLayout = useCallback( (event: { nativeEvent: { layout: { width: number; height: number } } }) => { @@ -377,7 +383,7 @@ function HoverCardSurface({ const surfaceStyle = useMemo(() => [styles.surface, style], [style]); return ( - +