mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-20 19:46:16 +00:00
fix(frontend): keep MCP configuration dialog within viewport (#5492)
* fix(frontend): keep MCP configuration dialog within viewport * fix(frontend): keep MCP dialog actions outside fallback scroll area
This commit is contained in:
parent
e831720304
commit
de02eeb856
@ -287,14 +287,16 @@ function MCPServerList({
|
|||||||
open={editor !== null}
|
open={editor !== null}
|
||||||
onOpenChange={(open) => !open && !isWriting && closeEditor()}
|
onOpenChange={(open) => !open && !isWriting && closeEditor()}
|
||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader className="shrink-0 pr-6 break-words">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{editor?.mode === "edit"
|
{editor?.mode === "edit"
|
||||||
? t.settings.tools.editServer
|
? t.settings.tools.editServer
|
||||||
: t.settings.tools.addServer}
|
: t.settings.tools.addServer}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
</DialogHeader>
|
||||||
|
<div className="-m-1 flex min-h-0 flex-col gap-4 overflow-y-auto p-1">
|
||||||
|
<DialogDescription className="shrink-0 text-center break-words sm:text-left">
|
||||||
{editor?.mode === "edit"
|
{editor?.mode === "edit"
|
||||||
? t.settings.tools.editServerDescription.replace(
|
? t.settings.tools.editServerDescription.replace(
|
||||||
"{name}",
|
"{name}",
|
||||||
@ -302,21 +304,24 @@ function MCPServerList({
|
|||||||
)
|
)
|
||||||
: t.settings.tools.addServerDescription}
|
: t.settings.tools.addServerDescription}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
<Textarea
|
||||||
<Textarea
|
className="field-sizing-fixed h-96 min-h-24 resize-none overflow-auto font-mono text-xs"
|
||||||
className="min-h-52 font-mono text-xs"
|
aria-label={t.settings.tools.serverDefinitionLabel}
|
||||||
aria-label={t.settings.tools.serverDefinitionLabel}
|
spellCheck={false}
|
||||||
spellCheck={false}
|
value={definition}
|
||||||
value={definition}
|
placeholder={t.settings.tools.addServerPlaceholder}
|
||||||
placeholder={t.settings.tools.addServerPlaceholder}
|
onChange={(event) => setDefinition(event.target.value)}
|
||||||
onChange={(event) => setDefinition(event.target.value)}
|
/>
|
||||||
/>
|
{definitionError && (
|
||||||
{definitionError && (
|
<div
|
||||||
<div className="text-destructive text-sm" role="alert">
|
className="text-destructive shrink-0 text-sm break-words"
|
||||||
{definitionError}
|
role="alert"
|
||||||
</div>
|
>
|
||||||
)}
|
{definitionError}
|
||||||
<DialogFooter>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={isWriting}
|
disabled={isWriting}
|
||||||
|
|||||||
@ -2,6 +2,169 @@ import { expect, test } from "@playwright/test";
|
|||||||
|
|
||||||
import { mockLangGraphAPI } from "./utils/mock-api";
|
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||||
|
|
||||||
|
for (const { viewport, name } of [
|
||||||
|
{ viewport: { width: 500, height: 280 }, name: "github" },
|
||||||
|
{
|
||||||
|
viewport: { width: 844, height: 390 },
|
||||||
|
name: `github-${"long-server-name-".repeat(30)}`,
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
test(`MCP dialog actions remain reachable during fallback scrolling at ${viewport.width}x${viewport.height}`, async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
mockLangGraphAPI(page);
|
||||||
|
const server = { enabled: false, command: "npx", args: ["example"] };
|
||||||
|
await page.route("**/api/mcp/config", (route) =>
|
||||||
|
route.fulfill({ json: { mcp_servers: { [name]: server } } }),
|
||||||
|
);
|
||||||
|
await page.goto("/workspace/capabilities");
|
||||||
|
|
||||||
|
for (const mode of ["edit", "add"] as const) {
|
||||||
|
await page
|
||||||
|
.getByRole("button", {
|
||||||
|
name: mode === "edit" ? `Edit ${name}` : "Add MCP plugin",
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
const dialog = page.getByRole("dialog");
|
||||||
|
const textbox = dialog.getByRole("textbox");
|
||||||
|
if (mode === "add") {
|
||||||
|
await textbox.fill(JSON.stringify({ mcpServers: { [name]: server } }));
|
||||||
|
// The duplicate-name validation message also contains user text.
|
||||||
|
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
||||||
|
await expect(dialog.getByRole("alert")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the actual overflowing ancestor, so this also exercises the
|
||||||
|
// pre-fix dialog-level fallback rather than assuming a wrapper exists.
|
||||||
|
expect(
|
||||||
|
await textbox.evaluate((element) => {
|
||||||
|
for (
|
||||||
|
let parent = element.parentElement;
|
||||||
|
parent;
|
||||||
|
parent = parent.parentElement
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
/auto|scroll/.test(getComputedStyle(parent).overflowY) &&
|
||||||
|
parent.scrollHeight > parent.clientHeight
|
||||||
|
) {
|
||||||
|
parent.scrollTop = parent.scrollHeight;
|
||||||
|
return parent.scrollTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}),
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const expectActionsVisible = async () => {
|
||||||
|
for (const element of [
|
||||||
|
dialog,
|
||||||
|
dialog.getByRole("heading"),
|
||||||
|
dialog.getByRole("button", { name: "Close", exact: true }),
|
||||||
|
dialog.getByRole("button", { name: "Save", exact: true }),
|
||||||
|
dialog.getByRole("button", { name: "Cancel", exact: true }),
|
||||||
|
]) {
|
||||||
|
await expect(element).toBeInViewport({ ratio: 1 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await expectActionsVisible();
|
||||||
|
await textbox.fill("{invalid");
|
||||||
|
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
||||||
|
await dialog.getByRole("alert").scrollIntoViewIfNeeded();
|
||||||
|
await expectActionsVisible();
|
||||||
|
await page.screenshot({
|
||||||
|
path: testInfo.outputPath(`${mode}-fallback.png`),
|
||||||
|
});
|
||||||
|
await dialog
|
||||||
|
.getByRole("button", {
|
||||||
|
name: mode === "edit" ? "Close" : "Cancel",
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const viewport of [
|
||||||
|
{ width: 1280, height: 720 },
|
||||||
|
{ width: 390, height: 667 },
|
||||||
|
{ width: 844, height: 390 },
|
||||||
|
]) {
|
||||||
|
test(`long MCP definitions stay usable at ${viewport.width}x${viewport.height}`, async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
mockLangGraphAPI(page);
|
||||||
|
const server = {
|
||||||
|
enabled: false,
|
||||||
|
description: "GitHub MCP server for repository operations",
|
||||||
|
type: "stdio",
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "@modelcontextprotocol/server-github"],
|
||||||
|
env: Object.fromEntries(
|
||||||
|
Array.from({ length: 60 }, (_, index) => [`TEST_${index}`, "example"]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
await page.route("**/api/mcp/config", (route) =>
|
||||||
|
route.fulfill({ json: { mcp_servers: { github: server } } }),
|
||||||
|
);
|
||||||
|
await page.goto("/workspace/capabilities");
|
||||||
|
|
||||||
|
for (const mode of ["edit", "add"] as const) {
|
||||||
|
await page
|
||||||
|
.getByRole("button", {
|
||||||
|
name: mode === "edit" ? "Edit github" : "Add MCP plugin",
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
const dialog = page.getByRole("dialog");
|
||||||
|
const textbox = dialog.getByRole("textbox");
|
||||||
|
if (mode === "add") {
|
||||||
|
await textbox.fill(
|
||||||
|
JSON.stringify({ mcpServers: { github: server } }, null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const expectWithinViewport = async () => {
|
||||||
|
for (const element of [
|
||||||
|
dialog,
|
||||||
|
dialog.getByRole("heading"),
|
||||||
|
textbox,
|
||||||
|
dialog.getByRole("button", { name: "Cancel", exact: true }),
|
||||||
|
dialog.getByRole("button", { name: "Save", exact: true }),
|
||||||
|
dialog.getByRole("button", { name: "Close", exact: true }),
|
||||||
|
]) {
|
||||||
|
await expect(element).toBeInViewport({ ratio: 1 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await expectWithinViewport();
|
||||||
|
expect(
|
||||||
|
await textbox.evaluate(
|
||||||
|
(element) => element.scrollHeight > element.clientHeight,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
await textbox.hover();
|
||||||
|
await page.mouse.wheel(0, 10000);
|
||||||
|
await expect
|
||||||
|
.poll(() => textbox.evaluate((element) => element.scrollTop))
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
await expectWithinViewport();
|
||||||
|
await page.screenshot({ path: testInfo.outputPath(`${mode}.png`) });
|
||||||
|
|
||||||
|
await textbox.fill("{invalid");
|
||||||
|
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
||||||
|
await expect(dialog.getByRole("alert")).toBeVisible();
|
||||||
|
await expectWithinViewport();
|
||||||
|
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
}
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test.describe("MCP server settings", () => {
|
test.describe("MCP server settings", () => {
|
||||||
test("edits one server without dropping advanced fields or siblings", async ({
|
test("edits one server without dropping advanced fields or siblings", async ({
|
||||||
page,
|
page,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user