diff --git a/frontend/src/components/AgentManageRow.tsx b/frontend/src/components/AgentManageRow.tsx index 7f646f91..cb2614d2 100644 --- a/frontend/src/components/AgentManageRow.tsx +++ b/frontend/src/components/AgentManageRow.tsx @@ -9,6 +9,7 @@ import { sourceTranslate, type Translate, useI18n } from "../i18n"; import { taskCanceller, taskKey, updateTaskRoute, useTaskCenter, useTaskRoute } from "../state/TaskCenterContext"; import type { AgentCatalogItem, AgentStatus, ProfileSummary, StatusResponse } from "../types/api"; import { AgentIcon, agentTagline } from "./icons/agents"; +import { ModalDialog } from "./ModalDialog"; type Providers = StatusResponse["providers"]; @@ -248,7 +249,7 @@ export function AgentManageRow({ {directoryDialog ? ( - + setDirectoryDialog(false)}>
{ event.preventDefault(); void confirmLaunch(); }}>

{t("选择启动目录")}

@@ -259,7 +260,7 @@ export function AgentManageRow({
-
+ ) : null}
{/* Always in the row, not only when the Agent cannot launch. Configuring diff --git a/frontend/src/components/ModalDialog.test.tsx b/frontend/src/components/ModalDialog.test.tsx new file mode 100644 index 00000000..8c28a412 --- /dev/null +++ b/frontend/src/components/ModalDialog.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { ModalDialog } from "./ModalDialog"; + +describe("ModalDialog", () => { + it("opens on the top layer rather than in document flow", () => { + // The regression this guards: `` is visible but not modal, so + // it stays at its static position inside whichever row rendered it. Only + // showModal() promotes the element to the top layer, so assert the call + // itself -- jsdom has no layout, and the position it produces is what the + // E2E suite checks. + const showModal = vi.spyOn(window.HTMLDialogElement.prototype, "showModal"); + render(

body

); + expect(showModal).toHaveBeenCalledTimes(1); + expect(screen.getByRole("dialog")).toHaveAttribute("open"); + showModal.mockRestore(); + }); + + it("labels the dialog so it is not announced as an unnamed region", () => { + render(

body

); + expect(screen.getByRole("dialog", { name: "选择启动目录" })).toBeTruthy(); + }); + + it("reports Esc to the caller instead of closing behind its state", () => { + // The caller's state decides whether the modal renders. If Esc closed the + // element directly, that state would still say "open" while the dialog was + // gone -- unreachable, and impossible to reopen. + const onDismiss = vi.fn(); + render(

body

); + const dialog = screen.getByRole("dialog") as HTMLDialogElement; + dialog.requestClose(); + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(dialog).toHaveAttribute("open"); + }); + + it("closes the element when the caller stops rendering it", async () => { + function Host() { + const [open, setOpen] = useState(true); + return ( + <> + + {open ? setOpen(false)}>

body

: null} + + ); + } + render(); + expect(screen.getByRole("dialog")).toBeTruthy(); + await userEvent.click(screen.getByRole("button", { name: "close" })); + expect(screen.queryByRole("dialog")).toBeNull(); + }); +}); diff --git a/frontend/src/components/ModalDialog.tsx b/frontend/src/components/ModalDialog.tsx new file mode 100644 index 00000000..55477dd5 --- /dev/null +++ b/frontend/src/components/ModalDialog.tsx @@ -0,0 +1,62 @@ +import { useLayoutEffect, useRef, type PropsWithChildren } from "react"; + +/** + * A modal that renders on the browser's top layer. + * + * The `open` attribute alone does not do this. It only makes a + * visible, and a visible non-modal dialog stays in normal document flow at its + * static position: no ::backdrop, no focus containment, no Esc. Every modal + * here is authored inside the row or page that owns its state, so under + * `open` the launch dialog rendered wherever its Agent row happened to sit -- + * far down a long list, adding its own height to the page instead of covering + * the window. `.app-window` also sets `overflow: hidden`, which clips anything + * that tries to escape by positioning alone. + * + * showModal() is what promotes the element to the top layer, above every + * stacking context and outside the clip. It also brings the parts we were + * otherwise hand-rolling or missing: a real ::backdrop, focus trapped inside, + * and Esc. Nothing needs a z-index, so a modal can no longer tie with the task + * centre's and lose the tie to DOM order. + */ +export function ModalDialog({ className, label, onDismiss, children }: PropsWithChildren<{ + className?: string; + label?: string; + /** + * Called when the user asks to close: Esc, or the backdrop-cancel the + * platform may add. Callers own the state that renders this component, so + * they close it by unmounting rather than by mutating the element. + */ + onDismiss?: () => void; +}>) { + const ref = useRef(null); + + // Layout, not passive: a plain effect leaves one frame in which the element is + // in the DOM but not yet on the top layer, so it paints once at its static + // position -- the very flash this component exists to remove -- and is not yet + // reachable as a dialog. + useLayoutEffect(() => { + const dialog = ref.current; + if (!dialog) return; + dialog.showModal(); + // Symmetric with the call above so a remount cannot open an already-open + // dialog. React 19 double-invokes effects under StrictMode. + return () => dialog.close(); + }, []); + + return ( + { + event.preventDefault(); + onDismiss?.(); + }} + > + {children} + + ); +} diff --git a/frontend/src/pages/MCPPage.tsx b/frontend/src/pages/MCPPage.tsx index 80292bf0..5476170c 100644 --- a/frontend/src/pages/MCPPage.tsx +++ b/frontend/src/pages/MCPPage.tsx @@ -8,6 +8,7 @@ import { AgentSummaryBar } from "../components/AgentSummaryBar"; import { AgentTargetGroup } from "../components/AgentTargetGroup"; import { EmptyState } from "../components/EmptyState"; import { ManagementSearch } from "../components/ManagementSearch"; +import { ModalDialog } from "../components/ModalDialog"; import { PageScaffold } from "../components/PageScaffold"; import { SelectField } from "../components/SelectField"; import { useWizard } from "../state/WizardContext"; @@ -200,6 +201,6 @@ export function MCPPage() { {rows.length ?
: null} {!rows.length ? : !visibleRows.length ? :
{visibleRows.map((row) => { const isDeleted = Boolean(deleted[row.id]); return ; })}
{t("服务器")}{t("传输")}{t("同步目标")}{t("状态")}
{row.id}{row.type} setTargets((all) => { const current = new Set(all[row.id] ?? agentsFor(row)); checked ? current.add(agent) : current.delete(agent); return { ...all, [row.id]: [...current] }; })} />{isDeleted ? t("待删除") : row.conflict ? t("冲突") : mcpRowPending(row.id, draft, targets) ? t("待应用") : t("已同步")}{row.has_secrets ? {t("包含秘密字段")} : null}
} - {editing !== null ?

{editing ? t("编辑 MCP 服务器") : t("新增 MCP 服务器")}

{t("传输")} { const next = changeMCPTransport(form, type); setForm(next); setCommandLine(type === "stdio" ? formatStdioCommandLine(next) : ""); setAdvancedJSON(JSON.stringify(next, null, 2)); }} label={t("传输")} />
{form.type === "stdio" ? : }