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.
This commit is contained in:
Mohamed Boudra
2026-10-19 19:00:23 +02:00
parent 05978bc5d0
commit ef885ddc8b
11 changed files with 85 additions and 37 deletions
+3 -1
View File
@@ -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
+1 -1
View File
@@ -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.
@@ -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<void> {
await page.setViewportSize({ width: 390, height: 844 });
}
async function reopenHostUiPluginFromCompactSidebar(page: Page): Promise<void> {
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
await page.getByRole("button", { name: "Host UI", exact: true }).click();
}
async function openPluginModal(page: Page): Promise<void> {
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);
@@ -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) {
@@ -15,6 +15,7 @@ async function closeMenuSheet(page: Page): Promise<void> {
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();
}
});
@@ -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
+4 -1
View File
@@ -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",
+6 -2
View File
@@ -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[] = [];
@@ -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;
}
+17 -11
View File
@@ -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<View | null>;
contentRef: RefObject<View | null>;
}): ReactElement {
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const portalHostName = useFloatingPanelPortalHostName();
const [anchor, setAnchor] = useState<{ triggerRect: Rect; hostRect: Rect } | null>(null);
const [contentSize, setContentSize] = useState<Size | null>(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 (
<Portal hostName={bottomSheetInternal?.hostName}>
<Portal hostName={portalHostName}>
<View pointerEvents="box-none" style={[styles.portalOverlay, { zIndex: layer }]}>
<FloatingSurface
ref={contentRef}
@@ -164,6 +164,21 @@ index 629c155..e1909ff 100644
return shouldIntercept
}
diff --git a/node_modules/react-native-gesture-handler/lib/commonjs/web/handlers/GestureHandler.js b/node_modules/react-native-gesture-handler/lib/commonjs/web/handlers/GestureHandler.js
index 96c5227..cbf1a88 100644
--- a/node_modules/react-native-gesture-handler/lib/commonjs/web/handlers/GestureHandler.js
+++ b/node_modules/react-native-gesture-handler/lib/commonjs/web/handlers/GestureHandler.js
@@ -58,7 +58,9 @@ class GestureHandler {
manager.setOnPointerMoveOver(this.onPointerMoveOver.bind(this));
manager.setOnPointerMoveOut(this.onPointerMoveOut.bind(this));
manager.setOnWheel(this.onWheel.bind(this));
- manager.registerListeners();
+ if (this.enabled) {
+ manager.registerListeners();
+ }
}
//
diff --git a/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js b/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js
index 76eb808..7c40526 100644
--- a/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js
@@ -184,6 +199,21 @@ index 76eb808..7c40526 100644
}
this.markAsOutOfBounds(adaptedEvent.pointerId);
this.trackedPointers.delete(adaptedEvent.pointerId);
diff --git a/node_modules/react-native-gesture-handler/lib/module/web/handlers/GestureHandler.js b/node_modules/react-native-gesture-handler/lib/module/web/handlers/GestureHandler.js
index 0791c66..2f41c77 100644
--- a/node_modules/react-native-gesture-handler/lib/module/web/handlers/GestureHandler.js
+++ b/node_modules/react-native-gesture-handler/lib/module/web/handlers/GestureHandler.js
@@ -52,7 +52,9 @@ export default class GestureHandler {
manager.setOnPointerMoveOver(this.onPointerMoveOver.bind(this));
manager.setOnPointerMoveOut(this.onPointerMoveOut.bind(this));
manager.setOnWheel(this.onWheel.bind(this));
- manager.registerListeners();
+ if (this.enabled) {
+ manager.registerListeners();
+ }
}
//
diff --git a/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js b/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js
index 6ce3b8f..8fe28bc 100644
--- a/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js
@@ -204,6 +234,21 @@ index 6ce3b8f..8fe28bc 100644
}
this.markAsOutOfBounds(adaptedEvent.pointerId);
this.trackedPointers.delete(adaptedEvent.pointerId);
diff --git a/node_modules/react-native-gesture-handler/src/web/handlers/GestureHandler.ts b/node_modules/react-native-gesture-handler/src/web/handlers/GestureHandler.ts
index ddb79a3..4e1274c 100644
--- a/node_modules/react-native-gesture-handler/src/web/handlers/GestureHandler.ts
+++ b/node_modules/react-native-gesture-handler/src/web/handlers/GestureHandler.ts
@@ -79,7 +79,9 @@ export default abstract class GestureHandler implements IGestureHandler {
manager.setOnPointerMoveOut(this.onPointerMoveOut.bind(this));
manager.setOnWheel(this.onWheel.bind(this));
- manager.registerListeners();
+ if (this.enabled) {
+ manager.registerListeners();
+ }
}
//
diff --git a/node_modules/react-native-gesture-handler/src/web/tools/PointerEventManager.ts b/node_modules/react-native-gesture-handler/src/web/tools/PointerEventManager.ts
index 18d3627..769b0fe 100644
--- a/node_modules/react-native-gesture-handler/src/web/tools/PointerEventManager.ts