(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 ? : {t("服务器")} {t("传输")} {t("同步目标")} {t("状态")} {visibleRows.map((row) => { const isDeleted = Boolean(deleted[row.id]); return {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} void openEdit(row)} title={t("编辑")}> setDeleted((all) => ({ ...all, [row.id]: !isDeleted }))} title={isDeleted ? t("恢复") : t("删除")}>{isDeleted ? : } ; })}
}
- {editing !== null ? : null}
+ {editing !== null ? setEditing(null)}>{editing ? t("编辑 MCP 服务器") : t("新增 MCP 服务器")} setEditing(null)} title={t("关闭")}> {t("服务器 ID")} setEditing(event.target.value)} disabled={Boolean(draft[editing])} /> {t("传输")} { const next = changeMCPTransport(form, type); setForm(next); setCommandLine(type === "stdio" ? formatStdioCommandLine(next) : ""); setAdvancedJSON(JSON.stringify(next, null, 2)); }} label={t("传输")} />
{form.type === "stdio" ? {t("命令")} updateCommandLine(event.target.value)} autoComplete="off" autoCapitalize="none" autoCorrect="off" spellCheck={false} /> : {t("URL")} { const next = { ...form, url: event.target.value }; setForm(next); setAdvancedJSON(JSON.stringify(next, null, 2)); }} autoComplete="off" autoCapitalize="none" autoCorrect="off" spellCheck={false} /> }{t("高级 JSON")} setEditing(null)}> {t("取消")} {t("保存草稿")} : null}
;
}
diff --git a/frontend/src/pages/SkillsPage.tsx b/frontend/src/pages/SkillsPage.tsx
index b3af994b..ecada6ce 100644
--- a/frontend/src/pages/SkillsPage.tsx
+++ b/frontend/src/pages/SkillsPage.tsx
@@ -7,6 +7,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 { useI18n } from "../i18n";
import { useWizard } from "../state/WizardContext";
@@ -154,6 +155,6 @@ export function SkillsPage() {
{rows.length ? : null}
{!rows.length ? : !visibleRows.length ? : {t("名称")} {t("详情")} {t("状态")} {t("选择目标 Agent")} {visibleRows.map((row) => { const candidate = candidateMap.get(row.id); const hash = candidate?.hash ?? changes[row.id]?.variant_hash ?? row.variant_hashes?.[0] ?? ""; const detail = row.description || t("暂无详情"); const isDeleted = Boolean(pendingDeletes[row.id]); return {row.name || row.id} {compactDescription(detail)} {row.id} {isDeleted ? {t("待删除")} : row.conflict ? {t("冲突")} : {row.variants} variant } selectTarget(row.id, hash, agent, checked, row.agents ?? [])} /> toggleDelete(row.id, row.agents ?? [])} disabled={busy} title={isDeleted ? t("恢复") : t("删除")}>{isDeleted ? : } ; })}
}
- {previewOpen ? {t("导入预览")} setPreviewOpen(false)} title={t("取消")}> {t("选择要导入的 Skill 和目标 Agent")}
{candidates.map((candidate) => { const change = changes[candidate.id] ?? { id: candidate.id, variant_hash: candidate.hash, targets: [] }; const detail = candidate.description || candidate.hash.slice(0, 12); return
; })}
setPreviewOpen(false)}>{t("取消")} selectedCandidates[item.id])}>{t("确认导入")} {busy ? t("应用中") : t("确认导入并应用")} : null}
+ {previewOpen ? setPreviewOpen(false)}>{t("导入预览")} setPreviewOpen(false)} title={t("取消")}> {t("选择要导入的 Skill 和目标 Agent")}
{candidates.map((candidate) => { const change = changes[candidate.id] ?? { id: candidate.id, variant_hash: candidate.hash, targets: [] }; const detail = candidate.description || candidate.hash.slice(0, 12); return ; })} setPreviewOpen(false)}>{t("取消")} selectedCandidates[item.id])}>{t("确认导入")} {busy ? t("应用中") : t("确认导入并应用")} : null}
;
}
diff --git a/frontend/src/pages/TransferPage.tsx b/frontend/src/pages/TransferPage.tsx
index c8620d27..08ba5740 100644
--- a/frontend/src/pages/TransferPage.tsx
+++ b/frontend/src/pages/TransferPage.tsx
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api, describeFailure } from "../backend/api";
+import { ModalDialog } from "../components/ModalDialog";
import { PageScaffold } from "../components/PageScaffold";
import { useI18n } from "../i18n";
import { byProviderCreatedAt } from "../state/ranking";
@@ -233,7 +234,11 @@ export function TransferPage() {
{failure ? {failure}
: null}
{success ? {success}
: null}
{passwordRequest ? (
-
+ finishPassword(null)}
+ >
-
+
) : null}
{encryptionRequest ? (
-
+ finishEncryption(null)}>
{/* Not including keys is the default and the submit action: a transfer
file describes which Providers and Profiles exist, which is useful on
its own, and carrying live credentials is what turns it into a
@@ -268,10 +273,10 @@ export function TransferPage() {
{t("不包含 Key")}
-
+
) : null}
{importPlan ? (
-
+ finishImport(false)}>
-
+
) : null}
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css
index d00a2d1f..bb98df68 100644
--- a/frontend/src/styles/app.css
+++ b/frontend/src/styles/app.css
@@ -2313,8 +2313,17 @@
.skills-detail-cell span[title], .skills-import-name small[title] { cursor: help; }
.skills-detail-cell small { margin-top: 4px; color: var(--text-tertiary); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.mcp-secret-note { display: block; color: var(--text-secondary); margin-top: 4px; }
-.mcp-modal-backdrop { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 24px; background: rgb(0 0 0 / 35%); }
-.mcp-modal { width: min(620px, 100%); max-height: min(720px, 90vh); overflow: auto; padding: 22px; background: var(--window-bg); border: 1px solid var(--border-strong); box-shadow: var(--shadow-lg); }
+/* The modal is now the itself, opened with showModal(), so the top
+ layer does the centring and covering a fixed wrapper used to do by hand. That
+ wrapper also carried `z-index: 20`, which only tied the task centre's 20 and
+ left DOM order to break the tie; the top layer is above every z-index.
+ Widths resolve against the viewport rather than the wrapper's padded cell, so
+ the small-window guard has to be explicit, as the transfer dialog's is.
+ `color` is restated because the UA sheet sets its own on , and a UA
+ declaration beats inheritance -- without it the text is black on the dark
+ theme's dark surface. */
+.mcp-modal::backdrop { background: rgb(0 0 0 / 35%); }
+.mcp-modal { width: min(620px, calc(100vw - 48px)); max-height: min(720px, 90vh); overflow: auto; padding: 22px; background: var(--window-bg); color: var(--text-primary); border: 1px solid var(--border-strong); box-shadow: var(--shadow-lg); }
.mcp-modal header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.mcp-modal footer { display: flex; align-items: center; justify-content: flex-end; gap: 12px; }
.mcp-modal label { display: grid; gap: 6px; margin: 16px 0; color: var(--text-secondary); font-size: 13px; }
@@ -2324,7 +2333,7 @@
.mcp-modal input, .mcp-modal select, .mcp-modal textarea { width: 100%; box-sizing: border-box; border: 1px solid var(--border-strong); background: var(--surface-subtle); color: var(--text-primary); padding: 9px 10px; font: inherit; }
.mcp-modal textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; resize: vertical; }
.mcp-modal footer { margin-top: 20px; }
-.skills-import-modal { width: min(760px, 100%); }
+.skills-import-modal { width: min(760px, calc(100vw - 48px)); }
.skills-import-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 24px; padding: 14px 0; border-top: 1px solid var(--border-faint); }
.skills-import-name { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; column-gap: 8px; row-gap: 4px; margin: 0 !important; }
.skills-import-name strong, .skills-import-name small { min-width: 0; overflow-wrap: anywhere; }
diff --git a/frontend/src/styles/modal-layer.test.ts b/frontend/src/styles/modal-layer.test.ts
new file mode 100644
index 00000000..6a0706df
--- /dev/null
+++ b/frontend/src/styles/modal-layer.test.ts
@@ -0,0 +1,65 @@
+/**
+ * Guards the one mechanism that puts modals above the page.
+ *
+ * Two ways of doing this had grown side by side. Four sites used ``,
+ * which is visible but *not* modal: it stays in document flow at its static
+ * position, so the launch dialog rendered inside its Agent row, well down a long
+ * list, and pushed the page instead of covering it. Two others hand-rolled a
+ * fixed overlay whose `z-index: 20` tied the task centre's 20, leaving DOM order
+ * to break the tie.
+ *
+ * Both are now one ModalDialog calling showModal(), which is above every
+ * stacking context and outside `.app-window`'s `overflow: hidden`. These are
+ * text assertions over source, as transfer-dialog.test.ts is, because jsdom does
+ * no layout and cannot fail on position.
+ */
+import { readdirSync, readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+// Built with node:path rather than `new URL("../", import.meta.url)`: Vite
+// statically rewrites that pattern into a dev-server asset URL, which is not a
+// file: URL and cannot be read from disk.
+const root = dirname(dirname(fileURLToPath(import.meta.url)));
+
+function sources(): { path: string; text: string }[] {
+ const found: { path: string; text: string }[] = [];
+ const walk = (dir: string, prefix: string) => {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ if (entry.isDirectory()) {
+ walk(join(dir, entry.name), `${prefix}${entry.name}/`);
+ continue;
+ }
+ if (!/\.tsx?$/.test(entry.name) || entry.name.includes(".test.")) continue;
+ found.push({ path: `${prefix}${entry.name}`, text: readFileSync(join(dir, entry.name), "utf8") });
+ }
+ };
+ walk(root, "");
+ return found;
+}
+
+describe("modal layering", () => {
+ it("renders a only through ModalDialog", () => {
+ const offenders = sources().filter((file) => file.path !== "components/ModalDialog.tsx" && /]/.test(file.text));
+ expect(offenders.map((file) => file.path)).toEqual([]);
+ });
+
+ it("opens with showModal, never with the open attribute", () => {
+ const component = sources().find((file) => file.path === "components/ModalDialog.tsx");
+ expect(component).toBeTruthy();
+ expect(component?.text).toContain("showModal()");
+ // `open` as a JSX attribute is the defect; the `dialog.open` property and
+ // the word in prose are not.
+ expect(/]*\sopen[\s/>]/.test(component?.text ?? "")).toBe(false);
+ });
+
+ it("keeps no competing fixed overlay for modals", () => {
+ const css = readFileSync(join(root, "styles/app.css"), "utf8");
+ expect(css).not.toContain("mcp-modal-backdrop");
+ // The backdrop comes from the top layer now, for both modal skins.
+ expect(css).toContain(".mcp-modal::backdrop");
+ expect(css).toContain(".transfer-password-dialog::backdrop");
+ });
+});
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
index d8dd1bd3..c80b6df0 100644
--- a/frontend/src/test/setup.ts
+++ b/frontend/src/test/setup.ts
@@ -69,3 +69,37 @@ beforeEach(() => {
installStorage("localStorage");
installStorage("sessionStorage");
});
+
+/**
+ * jsdom 30 ships no HTMLDialogElement methods at all -- `show`, `showModal`,
+ * `close` and `requestClose` are all undefined, though the `open` property does
+ * reflect the attribute. Modals call showModal() because that is what puts them
+ * on the top layer, so without this every test rendering one throws.
+ *
+ * There is no top layer to emulate here, and jsdom does no layout, so this
+ * tracks the observable state a test can assert on: `open`, the implicit
+ * "dialog" role that follows it, and the close/cancel events. Whether the
+ * element truly escapes its stacking context is a browser question, which is
+ * why the E2E suite is what covers position.
+ */
+const dialog = window.HTMLDialogElement.prototype;
+if (typeof dialog.showModal !== "function") {
+ dialog.show = function show(this: HTMLDialogElement) {
+ this.setAttribute("open", "");
+ };
+ dialog.showModal = function showModal(this: HTMLDialogElement) {
+ this.setAttribute("open", "");
+ };
+ dialog.close = function close(this: HTMLDialogElement, returnValue?: string) {
+ if (!this.hasAttribute("open")) return;
+ this.removeAttribute("open");
+ if (returnValue !== undefined) this.returnValue = returnValue;
+ this.dispatchEvent(new window.Event("close"));
+ };
+ dialog.requestClose = function requestClose(this: HTMLDialogElement, returnValue?: string) {
+ if (!this.hasAttribute("open")) return;
+ // Cancel is the event Esc fires, and it is preventable; only close when
+ // nothing prevented it, as the platform does.
+ if (this.dispatchEvent(new window.Event("cancel", { cancelable: true }))) this.close(returnValue);
+ };
+}