From 9b41b16b42d4c8bebe68de6170bef94df27c301d Mon Sep 17 00:00:00 2001 From: AAEE86 Date: Fri, 17 Jul 2026 17:42:42 +0800 Subject: [PATCH 1/2] feat(settings): open web settings in an in-page dialog - replace the web settings window with a responsive modal - reuse existing settings routes through a same-origin iframe - support loading state, close button, overlay and Escape dismissal - add tests for dialog events and interactions --- src/app/layout.tsx | 2 + .../settings/web-settings-dialog.test.tsx | 63 +++++++++++ .../settings/web-settings-dialog.tsx | 102 ++++++++++++++++++ src/lib/api.ts | 5 +- src/lib/settings-dialog-events.test.ts | 25 +++++ src/lib/settings-dialog-events.ts | 29 +++++ 6 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 src/components/settings/web-settings-dialog.test.tsx create mode 100644 src/components/settings/web-settings-dialog.tsx create mode 100644 src/lib/settings-dialog-events.test.ts create mode 100644 src/lib/settings-dialog-events.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 852464b20..0867d78d2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,6 +13,7 @@ import { OverlayScrollbarsInit } from "@/components/overlay-scrollbars-init" import { ClipboardFallbackInit } from "@/components/clipboard-fallback-init" import { WebConnectionGuard } from "@/components/connection/web-connection-guard" import { WindowResizeGrips } from "@/components/layout/window-resize-grips" +import { WebSettingsDialog } from "@/components/settings/web-settings-dialog" export const viewport: Viewport = { width: "device-width", @@ -74,6 +75,7 @@ export default async function RootLayout({ {children} + diff --git a/src/components/settings/web-settings-dialog.test.tsx b/src/components/settings/web-settings-dialog.test.tsx new file mode 100644 index 000000000..dfb3bd733 --- /dev/null +++ b/src/components/settings/web-settings-dialog.test.tsx @@ -0,0 +1,63 @@ +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +vi.mock("next-intl", () => ({ useTranslations: () => () => "Settings" })) + +import { WebSettingsDialog } from "./web-settings-dialog" +import { + emitOpenSettingsDialog, + OPEN_SETTINGS_DIALOG_EVENT, +} from "@/lib/settings-dialog-events" + +afterEach(() => cleanup()) + +describe("WebSettingsDialog", () => { + it("opens the requested settings route in an in-page dialog", () => { + render() + + act(() => emitOpenSettingsDialog("/settings/agents?agent=codex")) + + const frame = screen.getByTitle("Settings") + expect(frame).toHaveAttribute("src", "/settings/agents?agent=codex") + expect(screen.getByRole("dialog")).toBeInTheDocument() + }) + + it("closes and removes the settings frame", () => { + render() + act(() => emitOpenSettingsDialog("/settings/appearance")) + + fireEvent.click(screen.getByRole("button", { name: "Close" })) + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + expect(screen.queryByTitle("Settings")).not.toBeInTheDocument() + }) + + it("closes on Escape while focus is inside the settings frame", () => { + render() + act(() => emitOpenSettingsDialog("/settings/appearance")) + + const frame = screen.getByTitle("Settings") + fireEvent.load(frame) + act(() => { + frame.contentWindow?.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape" }) + ) + }) + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) + + it("ignores navigation outside the settings routes", () => { + render() + + act(() => { + window.dispatchEvent( + new CustomEvent(OPEN_SETTINGS_DIALOG_EVENT, { + detail: { path: "/login" }, + }) + ) + }) + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) +}) diff --git a/src/components/settings/web-settings-dialog.tsx b/src/components/settings/web-settings-dialog.tsx new file mode 100644 index 000000000..c5e7de310 --- /dev/null +++ b/src/components/settings/web-settings-dialog.tsx @@ -0,0 +1,102 @@ +"use client" + +import { + useCallback, + useEffect, + useRef, + useState, + type SyntheticEvent, +} from "react" +import { LoaderCircle } from "lucide-react" +import { useTranslations } from "next-intl" +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog" +import { + OPEN_SETTINGS_DIALOG_EVENT, + type OpenSettingsDialogDetail, +} from "@/lib/settings-dialog-events" + +export function WebSettingsDialog() { + const t = useTranslations("SettingsShell") + const frameSequence = useRef(0) + const [frame, setFrame] = useState<{ + path: string + sequence: number + } | null>(null) + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + const handleOpenSettings = (event: Event) => { + const nextPath = (event as CustomEvent).detail + ?.path + if (!nextPath?.startsWith("/settings/")) return + + setLoaded(false) + frameSequence.current += 1 + setFrame({ path: nextPath, sequence: frameSequence.current }) + } + + window.addEventListener(OPEN_SETTINGS_DIALOG_EVENT, handleOpenSettings) + return () => { + window.removeEventListener(OPEN_SETTINGS_DIALOG_EVENT, handleOpenSettings) + } + }, []) + + const handleOpenChange = useCallback((open: boolean) => { + if (open) return + setFrame(null) + setLoaded(false) + }, []) + + const handleFrameKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Escape") handleOpenChange(false) + }, + [handleOpenChange] + ) + + const handleFrameLoad = useCallback( + (event: SyntheticEvent) => { + setLoaded(true) + try { + event.currentTarget.contentWindow?.addEventListener( + "keydown", + handleFrameKeyDown + ) + } catch { + // The settings route is expected to be same-origin. Keep the close + // button usable if a deployment rewrites it to another origin. + } + }, + [handleFrameKeyDown] + ) + + return ( + + + {t("title")} + + {!loaded && ( +
+
+ )} + + {frame && ( +