Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions frontend/src/components/AgentManageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"];

Expand Down Expand Up @@ -248,7 +249,7 @@ export function AgentManageRow({
</div>
</div>
{directoryDialog ? (
<dialog className="transfer-password-dialog" open>
<ModalDialog className="transfer-password-dialog" label={t("选择启动目录")} onDismiss={() => setDirectoryDialog(false)}>
<form onSubmit={(event) => { event.preventDefault(); void confirmLaunch(); }}>
<h2>{t("选择启动目录")}</h2>
<label className="launch-directory-label" htmlFor={`launch-directory-${agentId}`}>{t("启动目录")}</label>
Expand All @@ -259,7 +260,7 @@ export function AgentManageRow({
<label className="launch-remember-row"><input type="checkbox" checked={rememberDirectory} onChange={(event) => setRememberDirectory(event.target.checked)} /><span>{t("记住此 Agent 的目录")}</span></label>
<footer><button className="button button-secondary" type="button" onClick={() => setDirectoryDialog(false)}>{t("取消")}</button><button className="button button-primary" type="submit">{t("启动")}</button></footer>
</form>
</dialog>
</ModalDialog>
) : null}
<div className="agent-manage-actions">
{/* Always in the row, not only when the Agent cannot launch. Configuring
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/components/ModalDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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: `<dialog open>` 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(<ModalDialog className="c"><p>body</p></ModalDialog>);
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(<ModalDialog label="选择启动目录"><p>body</p></ModalDialog>);
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(<ModalDialog onDismiss={onDismiss}><p>body</p></ModalDialog>);
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 (
<>
<button onClick={() => setOpen(false)}>close</button>
{open ? <ModalDialog onDismiss={() => setOpen(false)}><p>body</p></ModalDialog> : null}
</>
);
}
render(<Host />);
expect(screen.getByRole("dialog")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "close" }));
expect(screen.queryByRole("dialog")).toBeNull();
});
});
62 changes: 62 additions & 0 deletions frontend/src/components/ModalDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 <dialog>
* 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<HTMLDialogElement>(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 (
<dialog
ref={ref}
className={className}
aria-label={label}
// The state that renders this component is the single source of truth, so
// Esc must not close the element behind React's back -- that would leave
// a dialog the caller still believes is open, invisible and unreachable.
onCancel={(event) => {
event.preventDefault();
onDismiss?.();
}}
>
{children}
</dialog>
);
}
3 changes: 2 additions & 1 deletion frontend/src/pages/MCPPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -200,6 +201,6 @@ export function MCPPage() {
{rows.length ? <div className="management-toolbar"><ManagementSearch value={query} onValueChange={setQuery} placeholder={t("搜索 MCP 服务器")} /><AgentSummaryBar agents={eligibleAgents} counts={summaryCounts} total={summaryRows.length} onToggleAll={bulkToggle} labels={agentLabels} /></div> : null}
{!rows.length ? <EmptyState icon={Server} title={t("尚未发现 MCP 服务器")} hint={t("点击新增来登记第一个 MCP 服务器")} /> : !visibleRows.length ? <EmptyState icon={Server} title={t("没有匹配的 MCP 服务器")} hint={t("换一个关键词,或清空搜索")} /> : <div className="mcp-table-wrap"><table className="mcp-table"><thead><tr><th>{t("服务器")}</th><th>{t("传输")}</th><th>{t("同步目标")}</th><th>{t("状态")}</th><th /></tr></thead><tbody>{visibleRows.map((row) => { const isDeleted = Boolean(deleted[row.id]); return <tr key={row.id} className={isDeleted ? "mcp-row-deleted" : undefined}><td><strong>{row.id}</strong></td><td>{row.type}</td><td><AgentTargetGroup agents={eligibleAgents} selected={selectedFor(row)} disabled={isDeleted} labels={agentLabels} onToggle={(agent, checked) => setTargets((all) => { const current = new Set(all[row.id] ?? agentsFor(row)); checked ? current.add(agent) : current.delete(agent); return { ...all, [row.id]: [...current] }; })} /></td><td><span className={isDeleted ? "status-badge status-warning" : row.conflict ? "status-badge status-warning" : mcpRowPending(row.id, draft, targets) ? "status-badge status-info" : "status-badge status-success"}>{isDeleted ? t("待删除") : row.conflict ? t("冲突") : mcpRowPending(row.id, draft, targets) ? t("待应用") : t("已同步")}</span>{row.has_secrets ? <small className="mcp-secret-note">{t("包含秘密字段")}</small> : null}</td><td className="mcp-row-actions"><button className="icon-button row-action" disabled={isDeleted} onClick={() => void openEdit(row)} title={t("编辑")}><Edit3 size={16} /></button><button className={`icon-button row-action${isDeleted ? " is-restore" : " is-danger"}`} onClick={() => setDeleted((all) => ({ ...all, [row.id]: !isDeleted }))} title={isDeleted ? t("恢复") : t("删除")}>{isDeleted ? <RotateCcw size={16} /> : <Trash2 size={16} />}</button></td></tr>; })}</tbody></table></div>}
</section>
{editing !== null ? <div className="mcp-modal-backdrop"><div className="mcp-modal" role="dialog" aria-modal="true"><header><h2>{editing ? t("编辑 MCP 服务器") : t("新增 MCP 服务器")}</h2><button className="icon-button" onClick={() => setEditing(null)} title={t("关闭")}><X size={18} /></button></header><label>{t("服务器 ID")}<input value={editing} onChange={(event) => setEditing(event.target.value)} disabled={Boolean(draft[editing])} /></label><div className="mcp-transport-field"><span>{t("传输")}</span><SelectField value={form.type ?? "stdio"} options={[{ value: "stdio", label: "stdio" }, { value: "http", label: "http" }, { value: "sse", label: "sse" }]} onChange={(type) => { const next = changeMCPTransport(form, type); setForm(next); setCommandLine(type === "stdio" ? formatStdioCommandLine(next) : ""); setAdvancedJSON(JSON.stringify(next, null, 2)); }} label={t("传输")} /></div>{form.type === "stdio" ? <label>{t("命令")}<input value={commandLine} onChange={(event) => updateCommandLine(event.target.value)} autoComplete="off" autoCapitalize="none" autoCorrect="off" spellCheck={false} /></label> : <label>{t("URL")}<input value={form.url ?? ""} onChange={(event) => { const next = { ...form, url: event.target.value }; setForm(next); setAdvancedJSON(JSON.stringify(next, null, 2)); }} autoComplete="off" autoCapitalize="none" autoCorrect="off" spellCheck={false} /></label>}<label>{t("高级 JSON")}<textarea value={advancedJSON} onChange={(event) => updateAdvancedJSON(event.target.value)} onBlur={finishAdvancedJSON} rows={10} /></label><footer><button className="button" onClick={() => setEditing(null)}><X size={16} />{t("取消")}</button><button className="button button-primary" onClick={saveDraft}><Save size={16} />{t("保存草稿")}</button></footer></div></div> : null}
{editing !== null ? <ModalDialog className="mcp-modal" label={editing ? t("编辑 MCP 服务器") : t("新增 MCP 服务器")} onDismiss={() => setEditing(null)}><header><h2>{editing ? t("编辑 MCP 服务器") : t("新增 MCP 服务器")}</h2><button className="icon-button" onClick={() => setEditing(null)} title={t("关闭")}><X size={18} /></button></header><label>{t("服务器 ID")}<input value={editing} onChange={(event) => setEditing(event.target.value)} disabled={Boolean(draft[editing])} /></label><div className="mcp-transport-field"><span>{t("传输")}</span><SelectField value={form.type ?? "stdio"} options={[{ value: "stdio", label: "stdio" }, { value: "http", label: "http" }, { value: "sse", label: "sse" }]} onChange={(type) => { const next = changeMCPTransport(form, type); setForm(next); setCommandLine(type === "stdio" ? formatStdioCommandLine(next) : ""); setAdvancedJSON(JSON.stringify(next, null, 2)); }} label={t("传输")} /></div>{form.type === "stdio" ? <label>{t("命令")}<input value={commandLine} onChange={(event) => updateCommandLine(event.target.value)} autoComplete="off" autoCapitalize="none" autoCorrect="off" spellCheck={false} /></label> : <label>{t("URL")}<input value={form.url ?? ""} onChange={(event) => { const next = { ...form, url: event.target.value }; setForm(next); setAdvancedJSON(JSON.stringify(next, null, 2)); }} autoComplete="off" autoCapitalize="none" autoCorrect="off" spellCheck={false} /></label>}<label>{t("高级 JSON")}<textarea value={advancedJSON} onChange={(event) => updateAdvancedJSON(event.target.value)} onBlur={finishAdvancedJSON} rows={10} /></label><footer><button className="button" onClick={() => setEditing(null)}><X size={16} />{t("取消")}</button><button className="button button-primary" onClick={saveDraft}><Save size={16} />{t("保存草稿")}</button></footer></ModalDialog> : null}
</PageScaffold>;
}
3 changes: 2 additions & 1 deletion frontend/src/pages/SkillsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -154,6 +155,6 @@ export function SkillsPage() {
{rows.length ? <div className="management-toolbar"><ManagementSearch value={query} onValueChange={setQuery} placeholder={t("搜索 Skills")} /><AgentSummaryBar agents={eligible} counts={summaryCounts} total={summaryRows.length} onToggleAll={bulkToggle} disabled={busy} labels={agentLabels} /></div> : null}
{!rows.length ? <EmptyState icon={Sparkles} title={t("没有发现 Skills")} hint={t("通过导入文件夹或导入 ZIP 添加第一个 Skill")} /> : !visibleRows.length ? <EmptyState icon={Sparkles} title={t("没有匹配的 Skills")} hint={t("换一个关键词,或清空搜索")} /> : <div className="mcp-table-wrap"><table className="mcp-table"><thead><tr><th>{t("名称")}</th><th>{t("详情")}</th><th>{t("状态")}</th><th>{t("选择目标 Agent")}</th><th /></tr></thead><tbody>{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 <tr key={row.id} className={isDeleted ? "mcp-row-deleted" : undefined}><td className="skills-name-cell"><strong>{row.name || row.id}</strong></td><td className="skills-detail-cell"><span title={detail}>{compactDescription(detail)}</span><small>{row.id}</small></td><td>{isDeleted ? <span className="status-badge status-warning">{t("待删除")}</span> : row.conflict ? <span className="status-badge status-warning">{t("冲突")}</span> : <span className="status-badge status-success">{row.variants} variant</span>}</td><td><AgentTargetGroup agents={eligible} selected={skillSelectedAgents(row, changes)} disabled={!hash || isDeleted || busy} labels={agentLabels} onToggle={(agent, checked) => selectTarget(row.id, hash, agent, checked, row.agents ?? [])} /></td><td className="mcp-row-actions"><button className={`icon-button row-action${isDeleted ? " is-restore" : " is-danger"}`} onClick={() => toggleDelete(row.id, row.agents ?? [])} disabled={busy} title={isDeleted ? t("恢复") : t("删除")}>{isDeleted ? <RotateCcw size={16} /> : <Trash2 size={16} />}</button></td></tr>; })}</tbody></table></div>}
</section>
{previewOpen ? <div className="mcp-modal-backdrop"><div className="mcp-modal skills-import-modal" role="dialog" aria-modal="true"><header><h2>{t("导入预览")}</h2><button className="icon-button" onClick={() => setPreviewOpen(false)} title={t("取消")}><X size={18} /></button></header><p>{t("选择要导入的 Skill 和目标 Agent")}</p>{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 <div className="skills-import-row" key={`${candidate.id}-${candidate.hash}`}><label className="skills-import-name"><input type="checkbox" checked={selectedCandidates[candidate.id] ?? false} onChange={(event) => setSelectedCandidates((current) => ({ ...current, [candidate.id]: event.target.checked }))} /><strong>{candidate.name || candidate.id}</strong><small title={detail}>{compactDescription(detail)}</small></label><AgentTargetGroup agents={eligible} selected={change.targets ?? []} disabled={!selectedCandidates[candidate.id]} labels={agentLabels} onToggle={(agent, checked) => selectTarget(candidate.id, candidate.hash, agent, checked)} /></div>; })}<footer><button className="button button-secondary" onClick={() => setPreviewOpen(false)}>{t("取消")}</button><button className="button button-secondary" onClick={confirmImport} disabled={!candidates.some((item) => selectedCandidates[item.id])}>{t("确认导入")}</button><button className="button button-primary" onClick={applyImport} disabled={busy}>{busy ? t("应用中") : t("确认导入并应用")}</button></footer></div></div> : null}
{previewOpen ? <ModalDialog className="mcp-modal skills-import-modal" label={t("导入预览")} onDismiss={() => setPreviewOpen(false)}><header><h2>{t("导入预览")}</h2><button className="icon-button" onClick={() => setPreviewOpen(false)} title={t("取消")}><X size={18} /></button></header><p>{t("选择要导入的 Skill 和目标 Agent")}</p>{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 <div className="skills-import-row" key={`${candidate.id}-${candidate.hash}`}><label className="skills-import-name"><input type="checkbox" checked={selectedCandidates[candidate.id] ?? false} onChange={(event) => setSelectedCandidates((current) => ({ ...current, [candidate.id]: event.target.checked }))} /><strong>{candidate.name || candidate.id}</strong><small title={detail}>{compactDescription(detail)}</small></label><AgentTargetGroup agents={eligible} selected={change.targets ?? []} disabled={!selectedCandidates[candidate.id]} labels={agentLabels} onToggle={(agent, checked) => selectTarget(candidate.id, candidate.hash, agent, checked)} /></div>; })}<footer><button className="button button-secondary" onClick={() => setPreviewOpen(false)}>{t("取消")}</button><button className="button button-secondary" onClick={confirmImport} disabled={!candidates.some((item) => selectedCandidates[item.id])}>{t("确认导入")}</button><button className="button button-primary" onClick={applyImport} disabled={busy}>{busy ? t("应用中") : t("确认导入并应用")}</button></footer></ModalDialog> : null}
</PageScaffold>;
}
Loading
Loading