diff --git a/packages/app/e2e/regression/server-dialog-focus.spec.ts b/packages/app/e2e/regression/server-dialog-focus.spec.ts new file mode 100644 index 0000000000..c8788b98c8 --- /dev/null +++ b/packages/app/e2e/regression/server-dialog-focus.spec.ts @@ -0,0 +1,48 @@ +import { expect, test, type Route } from "@playwright/test" + +const server = "http://127.0.0.1:4097" + +test("nested server dialog keeps focus inside the top layer", async ({ page }) => { + await page.addInitScript((server) => { + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [server] })) + }, server) + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()) + if (url.origin !== server) return route.fallback() + if (url.pathname === "/api/event") { + return route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n', + }) + } + if (url.pathname === "/api/global/health" || url.pathname === "/api/health") { + return json(route, { healthy: true, version: "2.0.0" }) + } + return json(route, {}) + }) + + await page.goto("/") + await page.keyboard.press("Control+,") + const settings = page.locator(".settings-dialog") + await expect(settings).toBeVisible() + await settings.getByRole("tab", { name: "Servers" }).click() + await settings.getByRole("button", { name: "Add server" }).click() + + const editor = page.getByRole("dialog", { name: "Add server" }) + await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused() + const username = editor.getByPlaceholder("username") + const password = editor.getByPlaceholder("password") + await username.click() + await expect(username).toBeFocused() + await username.fill("kit") + await expect(username).toHaveValue("kit") + await page.keyboard.press("Tab") + await expect(password).toBeFocused() + await password.fill("secret") + await expect(password).toHaveValue("secret") +}) + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }) +} diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index 39ef8ea1c7..40df0cb3fb 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -81,6 +81,7 @@ function init() { let dispose: (() => void) | undefined let setClosing: ((closing: boolean) => void) | undefined + // Stacked dialogs render as sibling portals, so only the top layer may own the focus trap. const node = runWithOwner(owner, () => createRoot((d: () => void) => { dispose = d @@ -88,7 +89,7 @@ function init() { setClosing = setClosingSignal return ( { if (open) return