diff --git a/src/app/commit/page.tsx b/src/app/commit/page.tsx
index 55fda42b6..f673791d3 100644
--- a/src/app/commit/page.tsx
+++ b/src/app/commit/page.tsx
@@ -13,6 +13,8 @@ import { AppTitleBar } from "@/components/layout/app-title-bar"
import { AppToaster } from "@/components/ui/app-toaster"
import { getFolder } from "@/lib/api"
import { toErrorMessage } from "@/lib/app-error"
+import { emitCloseCommitDialog } from "@/lib/commit-dialog-events"
+import { isDesktop } from "@/lib/transport"
import type { FolderDetail } from "@/lib/types"
import { GitCredentialProvider } from "@/contexts/git-credential-context"
import { RemoteConnectionGate } from "@/contexts/remote-connection-context"
@@ -42,6 +44,11 @@ function CommitPageInner() {
const error = state.loadedId === normalizedFolderId ? state.error : null
const closeWindow = useCallback(async () => {
+ if (!isDesktop()) {
+ emitCloseCommitDialog()
+ return
+ }
+
try {
const win = await getCurrentWindow()
await win.close()
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 852464b20..8d37429e9 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -13,6 +13,8 @@ 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 { WebCommitDialog } from "@/components/layout/web-commit-dialog"
+import { WebSettingsDialog } from "@/components/settings/web-settings-dialog"
export const viewport: Viewport = {
width: "device-width",
@@ -74,6 +76,8 @@ export default async function RootLayout({
{children}
+
+
diff --git a/src/components/layout/web-commit-dialog.test.tsx b/src/components/layout/web-commit-dialog.test.tsx
new file mode 100644
index 000000000..08e3e3e5c
--- /dev/null
+++ b/src/components/layout/web-commit-dialog.test.tsx
@@ -0,0 +1,64 @@
+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"
+import { afterEach, describe, expect, it, vi } from "vitest"
+
+vi.mock("next-intl", () => ({ useTranslations: () => () => "Commit" }))
+
+import { WebCommitDialog } from "./web-commit-dialog"
+import {
+ emitCloseCommitDialog,
+ emitOpenCommitDialog,
+ OPEN_COMMIT_DIALOG_EVENT,
+} from "@/lib/commit-dialog-events"
+
+afterEach(() => cleanup())
+
+describe("WebCommitDialog", () => {
+ it("opens the requested commit route in an in-page dialog", () => {
+ render()
+
+ act(() => emitOpenCommitDialog("/commit?folderId=42"))
+
+ const frame = screen.getByTitle("Commit")
+ expect(frame).toHaveAttribute("src", "/commit?folderId=42")
+ expect(screen.getByRole("dialog")).toBeInTheDocument()
+ })
+
+ it("closes when the embedded commit page emits its close event", () => {
+ render()
+ act(() => emitOpenCommitDialog("/commit?folderId=42"))
+
+ act(() => emitCloseCommitDialog())
+
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
+ expect(screen.queryByTitle("Commit")).not.toBeInTheDocument()
+ })
+
+ it("closes on Escape while focus is inside the commit frame", () => {
+ render()
+ act(() => emitOpenCommitDialog("/commit?folderId=42"))
+
+ const frame = screen.getByTitle("Commit")
+ fireEvent.load(frame)
+ act(() => {
+ frame.contentWindow?.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Escape" })
+ )
+ })
+
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
+ })
+
+ it("ignores navigation outside the commit route", () => {
+ render()
+
+ act(() => {
+ window.dispatchEvent(
+ new CustomEvent(OPEN_COMMIT_DIALOG_EVENT, {
+ detail: { path: "/login" },
+ })
+ )
+ })
+
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
+ })
+})
diff --git a/src/components/layout/web-commit-dialog.tsx b/src/components/layout/web-commit-dialog.tsx
new file mode 100644
index 000000000..eeef11a15
--- /dev/null
+++ b/src/components/layout/web-commit-dialog.tsx
@@ -0,0 +1,124 @@
+"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 {
+ CLOSE_COMMIT_DIALOG_EVENT,
+ OPEN_COMMIT_DIALOG_EVENT,
+ type OpenCommitDialogDetail,
+} from "@/lib/commit-dialog-events"
+
+function isCommitPath(path: string): boolean {
+ try {
+ const url = new URL(path, window.location.origin)
+ const pathname = url.pathname
+ .replace(/\/index\.html$/, "")
+ .replace(/\.html$/, "")
+ .replace(/\/+$/, "")
+ return url.origin === window.location.origin && pathname === "/commit"
+ } catch {
+ return false
+ }
+}
+
+export function WebCommitDialog() {
+ const t = useTranslations("CommitPage")
+ const frameSequence = useRef(0)
+ const [frame, setFrame] = useState<{
+ path: string
+ sequence: number
+ } | null>(null)
+ const [loaded, setLoaded] = useState(false)
+
+ const closeDialog = useCallback(() => {
+ setFrame(null)
+ setLoaded(false)
+ }, [])
+
+ useEffect(() => {
+ const handleOpenCommit = (event: Event) => {
+ const nextPath = (event as CustomEvent).detail
+ ?.path
+ if (!nextPath || !isCommitPath(nextPath)) return
+
+ setLoaded(false)
+ frameSequence.current += 1
+ setFrame({ path: nextPath, sequence: frameSequence.current })
+ }
+
+ window.addEventListener(OPEN_COMMIT_DIALOG_EVENT, handleOpenCommit)
+ window.addEventListener(CLOSE_COMMIT_DIALOG_EVENT, closeDialog)
+ return () => {
+ window.removeEventListener(OPEN_COMMIT_DIALOG_EVENT, handleOpenCommit)
+ window.removeEventListener(CLOSE_COMMIT_DIALOG_EVENT, closeDialog)
+ }
+ }, [closeDialog])
+
+ const handleOpenChange = useCallback(
+ (open: boolean) => {
+ if (!open) closeDialog()
+ },
+ [closeDialog]
+ )
+
+ const handleFrameKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (event.key === "Escape") closeDialog()
+ },
+ [closeDialog]
+ )
+
+ const handleFrameLoad = useCallback(
+ (event: SyntheticEvent) => {
+ setLoaded(true)
+ try {
+ event.currentTarget.contentWindow?.addEventListener(
+ "keydown",
+ handleFrameKeyDown
+ )
+ } catch {
+ // The commit route is expected to be same-origin. The close button
+ // remains available if a deployment rewrites it to another origin.
+ }
+ },
+ [handleFrameKeyDown]
+ )
+
+ return (
+
+ )
+}
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 (
+
+ )
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 88443b1bf..93daecc0d 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -9,6 +9,8 @@ import {
import { getCodegToken } from "./transport/web-auth"
import { notifyWebUnauthorized } from "./transport/web-connection-store"
import { getCurrentEffectiveAppLocale } from "./i18n"
+import { emitOpenCommitDialog } from "./commit-dialog-events"
+import { emitOpenSettingsDialog } from "./settings-dialog-events"
import { TurnBusyError, isTurnInProgressRejection } from "./turn-busy"
import type { FolderThemeColor } from "./theme-presets"
import type {
@@ -1877,7 +1879,7 @@ export async function openCommitWindow(folderId: number): Promise {
"open_commit_window",
{ folderId, locale }
)
- window.open(result.path, `commit-${folderId}`)
+ emitOpenCommitDialog(result.path)
}
export type SettingsSection =
@@ -1908,7 +1910,7 @@ export async function openSettingsWindow(
remoteConnectionId: getActiveRemoteConnectionId(),
})
}
- // Web mode: open in new window
+ // Web mode: render the existing settings route in the page-level modal.
const result = await getTransport().call<{ path: string }>(
"open_settings_window",
{
@@ -1917,7 +1919,7 @@ export async function openSettingsWindow(
locale,
}
)
- window.open(result.path, `settings-${section ?? "general"}`)
+ emitOpenSettingsDialog(result.path)
}
export async function openProjectBootWindow(source?: string): Promise {
diff --git a/src/lib/commit-dialog-events.test.ts b/src/lib/commit-dialog-events.test.ts
new file mode 100644
index 000000000..22fa20d53
--- /dev/null
+++ b/src/lib/commit-dialog-events.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it, vi } from "vitest"
+import {
+ CLOSE_COMMIT_DIALOG_EVENT,
+ emitCloseCommitDialog,
+ emitOpenCommitDialog,
+ OPEN_COMMIT_DIALOG_EVENT,
+ type OpenCommitDialogDetail,
+} from "./commit-dialog-events"
+
+describe("commit dialog events", () => {
+ it("emits the commit path for the top-level dialog host", () => {
+ const listener =
+ vi.fn<(event: CustomEvent) => void>()
+ window.addEventListener(
+ OPEN_COMMIT_DIALOG_EVENT,
+ listener as EventListener,
+ {
+ once: true,
+ }
+ )
+
+ emitOpenCommitDialog("/commit?folderId=7")
+
+ expect(listener).toHaveBeenCalledOnce()
+ expect(listener.mock.calls[0][0].detail).toEqual({
+ path: "/commit?folderId=7",
+ })
+ })
+
+ it("emits a close request for the top-level dialog host", () => {
+ const listener = vi.fn()
+ window.addEventListener(CLOSE_COMMIT_DIALOG_EVENT, listener, { once: true })
+
+ emitCloseCommitDialog()
+
+ expect(listener).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/lib/commit-dialog-events.ts b/src/lib/commit-dialog-events.ts
new file mode 100644
index 000000000..79928a068
--- /dev/null
+++ b/src/lib/commit-dialog-events.ts
@@ -0,0 +1,32 @@
+export const OPEN_COMMIT_DIALOG_EVENT = "codeg:open-commit-dialog"
+export const CLOSE_COMMIT_DIALOG_EVENT = "codeg:close-commit-dialog"
+
+export interface OpenCommitDialogDetail {
+ path: string
+}
+
+function getDialogEventTarget(): Window | null {
+ if (typeof window === "undefined") return null
+
+ try {
+ if (window.top?.location.origin === window.location.origin) {
+ return window.top
+ }
+ } catch {
+ // A cross-origin embedding page is not allowed to receive these events.
+ }
+
+ return window
+}
+
+export function emitOpenCommitDialog(path: string): void {
+ getDialogEventTarget()?.dispatchEvent(
+ new CustomEvent(OPEN_COMMIT_DIALOG_EVENT, {
+ detail: { path },
+ })
+ )
+}
+
+export function emitCloseCommitDialog(): void {
+ getDialogEventTarget()?.dispatchEvent(new Event(CLOSE_COMMIT_DIALOG_EVENT))
+}
diff --git a/src/lib/settings-dialog-events.test.ts b/src/lib/settings-dialog-events.test.ts
new file mode 100644
index 000000000..88d70a01e
--- /dev/null
+++ b/src/lib/settings-dialog-events.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it, vi } from "vitest"
+import {
+ emitOpenSettingsDialog,
+ OPEN_SETTINGS_DIALOG_EVENT,
+ type OpenSettingsDialogDetail,
+} from "./settings-dialog-events"
+
+describe("emitOpenSettingsDialog", () => {
+ it("emits the settings path for the top-level dialog host", () => {
+ const listener =
+ vi.fn<(event: CustomEvent) => void>()
+ window.addEventListener(
+ OPEN_SETTINGS_DIALOG_EVENT,
+ listener as EventListener,
+ { once: true }
+ )
+
+ emitOpenSettingsDialog("/settings/mcp")
+
+ expect(listener).toHaveBeenCalledOnce()
+ expect(listener.mock.calls[0][0].detail).toEqual({
+ path: "/settings/mcp",
+ })
+ })
+})
diff --git a/src/lib/settings-dialog-events.ts b/src/lib/settings-dialog-events.ts
new file mode 100644
index 000000000..1ec715c0a
--- /dev/null
+++ b/src/lib/settings-dialog-events.ts
@@ -0,0 +1,29 @@
+export const OPEN_SETTINGS_DIALOG_EVENT = "codeg:open-settings-dialog"
+
+export interface OpenSettingsDialogDetail {
+ path: string
+}
+
+/**
+ * Ask the top-level Codeg page to show settings in its modal. Settings are
+ * rendered in a same-origin iframe, so events emitted from inside that frame
+ * must target the top window to avoid opening a nested dialog.
+ */
+export function emitOpenSettingsDialog(path: string): void {
+ if (typeof window === "undefined") return
+
+ let target: Window = window
+ try {
+ if (window.top?.location.origin === window.location.origin) {
+ target = window.top
+ }
+ } catch {
+ // A cross-origin embedding page is not allowed to receive this event.
+ }
+
+ target.dispatchEvent(
+ new CustomEvent(OPEN_SETTINGS_DIALOG_EVENT, {
+ detail: { path },
+ })
+ )
+}