From 71560c50ec616f461583a9e7c3401f0d6c48e62f Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 8 Sep 2026 16:07:27 +0545 Subject: [PATCH 1/6] Add plugin configuration drawer tests --- .../plugins-ui/test/mcp-preset-cards.test.tsx | 132 +++----------- .../test/plugin-connect-panel.test.tsx | 162 ++++++++++++++++-- .../plugins-ui/test/plugins-gallery.test.tsx | 37 +++- 3 files changed, 202 insertions(+), 129 deletions(-) diff --git a/packages/plugins-ui/test/mcp-preset-cards.test.tsx b/packages/plugins-ui/test/mcp-preset-cards.test.tsx index 2f60d5004..70a517939 100644 --- a/packages/plugins-ui/test/mcp-preset-cards.test.tsx +++ b/packages/plugins-ui/test/mcp-preset-cards.test.tsx @@ -11,6 +11,7 @@ import type { Root } from "react-dom/client"; import { MCP_PRESETS } from "@workbench/templates/connectors"; import { McpPresetCard, useMcpPresetCatalog } from "../src/mcp-preset-cards"; +import type { McpPreset } from "../src/mcp-servers-api"; const realFetch = globalThis.fetch; let mountedRoots: Root[] = []; @@ -24,18 +25,22 @@ afterEach(() => { const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 10))); -function mountSection() { +function mountSection(onOpen: (preset: McpPreset) => void = () => {}) { const container = document.createElement("div"); document.body.appendChild(container); const root: Root = createRoot(container); mountedRoots.push(root); act(() => { - root.render(); + root.render(); }); return container; } -function PresetCatalogHarness() { +function PresetCatalogHarness({ + onOpen, +}: { + readonly onOpen: (preset: McpPreset) => void; +}) { const catalog = useMcpPresetCatalog("tenant_test"); if (!catalog.loaded) return null; if (catalog.loadError !== null) { @@ -52,6 +57,7 @@ function PresetCatalogHarness() { onChanged={(toolCount) => catalog.handleChanged(preset.slug, toolCount) } + onOpen={() => onOpen(preset)} /> ))} @@ -350,7 +356,7 @@ describe("MCP preset catalog", () => { expect(canvaCard.textContent).not.toContain("tools"); }); - test("Manage reveals a named Disconnect confirmation (CL-6794)", async () => { + test("Manage opens the preset drawer instead of expanding its catalog row", async () => { globalThis.fetch = (async () => new Response( JSON.stringify({ @@ -360,7 +366,8 @@ describe("MCP preset catalog", () => { }), )) as unknown as typeof fetch; - const container = mountSection(); + const opened: string[] = []; + const container = mountSection((preset) => opened.push(preset.slug)); await settle(); const exaCard = container.querySelector( @@ -369,16 +376,14 @@ describe("MCP preset catalog", () => { const manageExa = [...exaCard.querySelectorAll("button")].find( (button) => button.textContent?.includes("Manage") === true, ); - expect(manageExa?.textContent).toContain("Exa"); + expect(manageExa).not.toBeUndefined(); act(() => { manageExa?.click(); }); - const disconnectExa = [...exaCard.querySelectorAll("button")].find( - (button) => button.textContent?.includes("Disconnect") === true, - ); - expect(disconnectExa?.textContent).toContain("Exa"); + expect(opened).toEqual(["exa"]); + expect(exaCard.querySelector("input")).toBeNull(); }); test("connect calls the preset connect route with the preset's slug", async () => { @@ -430,32 +435,19 @@ describe("MCP preset catalog", () => { expect(container.textContent).toContain("4 tools"); }); - test("a token preset opens step-by-step guidance and posts the pasted token", async () => { + test("a token preset opens its drawer without expanding the catalog row", async () => { const calls: { url: string; init?: RequestInit }[] = []; - let connected = false; globalThis.fetch = (async (url: string, init?: RequestInit) => { calls.push({ url, ...(init !== undefined ? { init } : {}) }); - if (init?.method === "POST") { - connected = true; - return new Response( - JSON.stringify({ - slug: "github-mcp", - name: "GitHub MCP", - url: "https://api.githubcopilot.com/mcp/", - toolCount: 40, - }), - ); - } return new Response( JSON.stringify({ - data: PRESETS.map((p) => - p.slug === "github-mcp" ? { ...p, connected } : p, - ), + data: PRESETS, }), ); }) as unknown as typeof fetch; - const container = mountSection(); + const opened: string[] = []; + const container = mountSection((preset) => opened.push(preset.slug)); await settle(); const card = container.querySelector( @@ -470,90 +462,10 @@ describe("MCP preset catalog", () => { await new Promise((resolve) => setTimeout(resolve, 10)); }); - // Opening the form is not a connect — no POST yet, steps visible. + // Opening the drawer is not a connect and does not change row height. expect(calls.find((call) => call.init?.method === "POST")).toBeUndefined(); - expect(card.textContent).toContain( - "Open github.com/settings/tokens and generate a new token.", - ); - expect(card.textContent).toContain("Give it the repo scope."); - expect( - card.querySelector('a[href="https://github.com/settings/tokens"]'), - ).not.toBeNull(); - - const field = card.querySelector( - "#mcp-preset-token-github-mcp", - ) as HTMLInputElement; - expect(field).not.toBeNull(); - await act(async () => { - const setter = Object.getOwnPropertyDescriptor( - HTMLInputElement.prototype, - "value", - )?.set; - setter?.call(field, "ghp_pasted"); - field.dispatchEvent(new Event("input", { bubbles: true })); - }); - - const submitButton = [...card.querySelectorAll("button")].find( - (button) => button.textContent === "Connect", - ) as HTMLButtonElement; - await act(async () => { - submitButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - const connectCall = calls.find((call) => call.init?.method === "POST"); - expect(connectCall?.url).toBe("/api/tenants/tenant_test/mcp-servers"); - const body: unknown = JSON.parse(connectCall?.init?.body as string); - expect(body).toMatchObject({ - presetSlug: "github-mcp", - token: "ghp_pasted", - }); - expect(container.textContent).toContain("40 tools"); - }); - - test("disconnect calls DELETE on the preset's slug", async () => { - const calls: { url: string; init?: RequestInit }[] = []; - let deleted = false; - globalThis.fetch = (async (url: string, init?: RequestInit) => { - calls.push({ url, ...(init !== undefined ? { init } : {}) }); - if (init?.method === "DELETE") { - deleted = true; - return new Response(null, { status: 204 }); - } - return new Response( - JSON.stringify({ - data: PRESETS.map((p) => - p.slug === "exa" ? { ...p, connected: !deleted } : p, - ), - }), - ); - }) as unknown as typeof fetch; - - const container = mountSection(); - await settle(); - - const exaCard = container.querySelector( - '[data-plugin-slug="exa"]', - ) as HTMLElement; - const manageButton = [...exaCard.querySelectorAll("button")].find( - (button) => button.textContent?.includes("Manage"), - ) as HTMLButtonElement; - - await act(async () => { - manageButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - const confirmButton = [...container.querySelectorAll("button")].find( - (button) => button.textContent?.includes("Disconnect") === true, - ) as HTMLButtonElement; - await act(async () => { - confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - const deleteCall = calls.find((call) => call.init?.method === "DELETE"); - expect(deleteCall).not.toBeUndefined(); - expect(deleteCall?.url).toBe("/api/tenants/tenant_test/mcp-servers/exa"); + expect(opened).toEqual(["github-mcp"]); + expect(card.querySelector("input")).toBeNull(); }); // CL-6472: a fresh bench with zero connections still owns the same diff --git a/packages/plugins-ui/test/plugin-connect-panel.test.tsx b/packages/plugins-ui/test/plugin-connect-panel.test.tsx index 725d42ecf..59b5f78a0 100644 --- a/packages/plugins-ui/test/plugin-connect-panel.test.tsx +++ b/packages/plugins-ui/test/plugin-connect-panel.test.tsx @@ -12,7 +12,11 @@ import type { Root } from "react-dom/client"; import type { ConnectorDescriptor } from "@corbits/connections/registry"; import type { ResolvedPlugin } from "@corbits/connections/plugins"; -import { PluginConnectPanel } from "../src/plugin-connect-panel"; +import { + PluginConnectPanel, + type PluginPanelSubject, +} from "../src/plugin-connect-panel"; +import type { McpPreset } from "../src/mcp-servers-api"; import { PLUGINS_STRINGS } from "../src/strings"; const realFetch = globalThis.fetch; @@ -66,7 +70,14 @@ const settle = () => // Dialog content renders through a Radix portal appended to // `document.body`, not inside the mount container — every assertion below // reads from `document.body` for that reason. -function render(plugin: ResolvedPlugin | null) { +function connectorSubject(plugin: ResolvedPlugin): PluginPanelSubject { + return { kind: "connector", plugin }; +} + +function render( + subject: PluginPanelSubject | null, + onChanged: (toolCount?: number) => void = () => {}, +) { const container = document.createElement("div"); document.body.appendChild(container); const root: Root = createRoot(container); @@ -75,9 +86,9 @@ function render(plugin: ResolvedPlugin | null) { root.render( {}} - onChanged={() => {}} + onChanged={onChanged} />, ); }); @@ -87,7 +98,9 @@ function render(plugin: ResolvedPlugin | null) { describe("PluginConnectPanel", () => { test("an oauth-pkce connector shows an OAuth connect link, not a key form", () => { const container = render( - notConnected(descriptor("huggingface", "Hugging Face", "oauth-pkce")), + connectorSubject( + notConnected(descriptor("huggingface", "Hugging Face", "oauth-pkce")), + ), ); const link = container.querySelector("a"); @@ -99,7 +112,9 @@ describe("PluginConnectPanel", () => { // CL-6377: one Connect action — no separate test step or "Test" copy. test("an api-key connector shows the connect form", () => { - const container = render(notConnected(descriptor("exa", "Exa", "api-key"))); + const container = render( + connectorSubject(notConnected(descriptor("exa", "Exa", "api-key"))), + ); expect(container.querySelector('input[type="password"]')).not.toBeNull(); expect(container.textContent).toContain("Connect"); @@ -131,7 +146,9 @@ describe("PluginConnectPanel", () => { }) as unknown as typeof fetch; const container = render( - notConnected(descriptor("granola", "Granola", "api-key")), + connectorSubject( + notConnected(descriptor("granola", "Granola", "api-key")), + ), ); await settle(); @@ -145,13 +162,15 @@ describe("PluginConnectPanel", () => { new Response(JSON.stringify({ error: "nope" }), { status: 500 }), )) as unknown as typeof fetch; - const container = render({ - descriptor: descriptor("github", "GitHub", "api-key"), - status: "connected", - provenance: "this-workbench", - credentialId: "cred_github", - credentialName: "GitHub", - }); + const container = render( + connectorSubject({ + descriptor: descriptor("github", "GitHub", "api-key"), + status: "connected", + provenance: "this-workbench", + credentialId: "cred_github", + credentialName: "GitHub", + }), + ); const disconnectButton = [...container.querySelectorAll("button")].find( (button) => button.textContent?.includes("Disconnect") === true, @@ -195,7 +214,9 @@ describe("PluginConnectPanel", () => { headers: { "content-type": "application/json" }, })) as unknown as typeof fetch; - const container = render(notConnected(githubDescriptor())); + const container = render( + connectorSubject(notConnected(githubDescriptor())), + ); await settle(); const link = container.querySelector("a"); @@ -210,7 +231,9 @@ describe("PluginConnectPanel", () => { headers: { "content-type": "application/json" }, })) as unknown as typeof fetch; - const container = render(notConnected(githubDescriptor())); + const container = render( + connectorSubject(notConnected(githubDescriptor())), + ); await settle(); expect(container.textContent).toContain( @@ -225,7 +248,9 @@ describe("PluginConnectPanel", () => { globalThis.fetch = (() => Promise.reject(new Error("network down"))) as unknown as typeof fetch; - const container = render(notConnected(githubDescriptor())); + const container = render( + connectorSubject(notConnected(githubDescriptor())), + ); await settle(); expect(container.textContent).toContain("Couldn't check"); @@ -249,7 +274,9 @@ describe("PluginConnectPanel", () => { }); }) as unknown as typeof fetch; - const container = render(notConnected(githubDescriptor())); + const container = render( + connectorSubject(notConnected(githubDescriptor())), + ); await settle(); const retry = [...container.querySelectorAll("button")].find( @@ -272,4 +299,103 @@ describe("PluginConnectPanel", () => { expect(container.querySelector('input[type="password"]')).toBeNull(); expect(container.querySelector("a")).toBeNull(); }); + + test("a token preset renders its guidance and submits the pasted token", async () => { + const preset: McpPreset = { + slug: "github-mcp", + displayName: "GitHub MCP", + description: "Search code, work with issues and pull requests.", + url: "https://api.githubcopilot.com/mcp/", + connectionMode: "token", + docsUrl: "https://github.com/settings/tokens", + tokenSteps: ["Create a token with repo scope."], + connected: false, + }; + const calls: { url: string; init?: RequestInit }[] = []; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + calls.push({ url, ...(init === undefined ? {} : { init }) }); + return new Response( + JSON.stringify({ + slug: preset.slug, + name: preset.displayName, + url: preset.url, + toolCount: 40, + }), + ); + }) as unknown as typeof fetch; + + const changed: number[] = []; + const container = render( + { kind: "mcp-preset", preset, toolCount: undefined }, + (toolCount) => { + if (toolCount !== undefined) changed.push(toolCount); + }, + ); + const field = container.querySelector( + "#mcp-preset-token-github-mcp", + ) as HTMLInputElement; + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set; + await act(async () => { + setter?.call(field, "ghp_pasted"); + field.dispatchEvent(new Event("input", { bubbles: true })); + }); + const connect = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "Connect", + ); + await act(async () => { + connect?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe("/api/tenants/ten_1/mcp-servers"); + expect(JSON.parse(String(calls[0]?.init?.body))).toMatchObject({ + presetSlug: "github-mcp", + token: "ghp_pasted", + }); + expect(changed).toEqual([40]); + }); + + test("a connected preset disconnects from the drawer", async () => { + const preset: McpPreset = { + slug: "exa", + displayName: "Exa", + description: "Search and research the live web.", + url: "https://mcp.exa.ai/mcp", + connectionMode: "keyless", + docsUrl: "https://exa.ai", + connected: true, + }; + const calls: { url: string; init?: RequestInit }[] = []; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + calls.push({ url, ...(init === undefined ? {} : { init }) }); + return new Response(null, { status: 204 }); + }) as unknown as typeof fetch; + + const container = render({ + kind: "mcp-preset", + preset, + toolCount: 2, + }); + const disconnect = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Disconnect") === true, + ); + act(() => { + disconnect?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + const confirm = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Disconnect") === true, + ); + await act(async () => { + confirm?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe("/api/tenants/ten_1/mcp-servers/exa"); + expect(calls[0]?.init?.method).toBe("DELETE"); + }); }); diff --git a/packages/plugins-ui/test/plugins-gallery.test.tsx b/packages/plugins-ui/test/plugins-gallery.test.tsx index dfccde63a..ade5aa372 100644 --- a/packages/plugins-ui/test/plugins-gallery.test.tsx +++ b/packages/plugins-ui/test/plugins-gallery.test.tsx @@ -29,8 +29,9 @@ function plugin( id: string, displayName: string, status: ResolvedPlugin["status"], + authKind: ConnectorDescriptor["authKind"] = "api-key", ): ResolvedPlugin { - const pluginDescriptor = descriptor(id, displayName); + const pluginDescriptor = descriptor(id, displayName, authKind); if (status === "not_connected") { return { descriptor: pluginDescriptor, @@ -294,6 +295,40 @@ describe("PluginsGallery", () => { expect(exa?.textContent).toContain("Manage"); }); + test("an OAuth plugin starts authorization from Connect instead of opening a drawer", async () => { + const { container } = await renderGallery([ + plugin("huggingface", "Hugging Face", "not_connected", "oauth-pkce"), + ]); + + const connect = container.querySelector( + '[aria-label="Connect Hugging Face"]', + ); + expect(connect?.tagName).toBe("A"); + expect(connect?.getAttribute("href")).toBe( + "/api/tenants/tenant_test/connections/oauth/huggingface/start?return=%2Fplugins", + ); + expect(document.body.querySelector('[role="dialog"]')).toBeNull(); + }); + + test("a token preset keeps its catalog row compact and collects credentials in a drawer", async () => { + const { container } = await renderGallery(); + const githubMcp = container.querySelector( + '[data-plugin-slug="github-mcp"]', + ); + const connect = githubMcp?.querySelector( + '[aria-label="Connect GitHub MCP"]', + ) as HTMLButtonElement | null; + + act(() => connect?.click()); + + expect(githubMcp?.querySelector("input")).toBeNull(); + const dialog = document.body.querySelector('[role="dialog"]'); + expect(dialog?.textContent).toContain("GitHub MCP"); + expect( + dialog?.querySelector("#mcp-preset-token-github-mcp"), + ).not.toBeNull(); + }); + test("status remains visible as a core field", async () => { const { container } = await renderGallery(); const caption = [...container.querySelectorAll("span")].find( From f381bc88987b9b495a0625ebb71258e57c0d2f80 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 8 Sep 2026 16:08:00 +0545 Subject: [PATCH 2/6] Move plugin configuration into a drawer --- apps/web/src/pages/plugins-page.tsx | 12 +- packages/plugins-ui/src/index.ts | 5 +- packages/plugins-ui/src/mcp-preset-cards.tsx | 110 ++-------- packages/plugins-ui/src/plugin-card.tsx | 18 +- .../plugins-ui/src/plugin-connect-panel.tsx | 201 ++++++++++++++++-- packages/plugins-ui/src/plugins-gallery.tsx | 35 +++ 6 files changed, 262 insertions(+), 119 deletions(-) diff --git a/apps/web/src/pages/plugins-page.tsx b/apps/web/src/pages/plugins-page.tsx index 8f2ba489f..e618ce583 100644 --- a/apps/web/src/pages/plugins-page.tsx +++ b/apps/web/src/pages/plugins-page.tsx @@ -20,6 +20,7 @@ import { PluginsGallery, PluginConnectPanel, type PluginsGalleryTab, + type PluginPanelSubject, } from "@corbits/plugins-ui"; import type { ResolvedPlugin } from "@corbits/connections/plugins"; import { listPluginsForTenant } from "@corbits/connections/plugins"; @@ -81,7 +82,7 @@ export function PluginsRoute({ const [skillsState, setSkillsState] = useState({ status: "loading", }); - const [openPlugin, setOpenPlugin] = useState(null); + const [openPlugin, setOpenPlugin] = useState(null); const [createSkillOpen, setCreateSkillOpen] = useState(false); const [activeTab, setActiveTab] = useState("plugins"); const [galleryQuery, setGalleryQuery] = useState(""); @@ -100,7 +101,7 @@ export function PluginsRoute({ const clearPendingConnectProvider = useClearPendingConnectProvider(); const requestPluginsConnect = useRequestPluginsConnect(); const openPluginPanel = useCallback((plugin: ResolvedPlugin) => { - setOpenPlugin(plugin); + setOpenPlugin({ kind: "connector", plugin }); setConnectDeepLinkNotFound(false); }, []); @@ -373,9 +374,12 @@ export function PluginsRoute({ setOpenPlugin(null)} - onChanged={reloadPlugins} + onChanged={() => { + reloadPlugins(); + setOpenPlugin(null); + }} /> void; + readonly onOpen: () => void; }) { const [busy, setBusy] = useState(false); const [error, setError] = useState(() => mcpOauthReturnError(preset.slug), ); - const [tokenFieldOpen, setTokenFieldOpen] = useState(false); - const [token, setToken] = useState(""); - - function submitConnect(pastedToken: string | undefined) { + function submitConnect() { setBusy(true); setError(null); - connectMcpPreset(tenantId, preset.slug, pastedToken) + connectMcpPreset(tenantId, preset.slug, undefined) .then((result) => { toast( `Connected — ${result.toolCount} tool${result.toolCount === 1 ? "" : "s"} available.`, ); - setTokenFieldOpen(false); - setToken(""); onChanged(result.toolCount); }) .catch((cause: unknown) => setError(messageOf(cause))) @@ -106,22 +101,10 @@ export function McpPresetCard({ return; } if (preset.connectionMode === "token") { - setTokenFieldOpen(true); + onOpen(); return; } - submitConnect(undefined); - } - - function handleDisconnect() { - setBusy(true); - setError(null); - disconnectMcpServer(tenantId, preset.slug) - .then(() => { - toast(`${preset.displayName} disconnected.`); - onChanged(); - }) - .catch(() => setError(PLUGINS_STRINGS.disconnectError)) - .finally(() => setBusy(false)); + submitConnect(); } const presetDefinition = MCP_PRESETS.find( @@ -137,8 +120,6 @@ export function McpPresetCard({ : `${toolCount} tool${toolCount === 1 ? "" : "s"}` : "Not connected"; - const tokenFieldId = `mcp-preset-token-${preset.slug}`; - return (
{status} {preset.connected ? ( - - Disconnect - {preset.displayName} - - } - disabled={busy} - onConfirm={handleDisconnect} + variant="ghost" + aria-label={`Manage ${preset.displayName}`} + onClick={onOpen} > - {busy ? "Disconnecting…" : "Manage"} + Manage {preset.displayName} - - ) : tokenFieldOpen ? null : ( + + ) : (
- {tokenFieldOpen && !preset.connected ? ( -
-
    - {(preset.tokenSteps ?? []).map((step) => ( -
  1. {step}
  2. - ))} -
- - Create your token - - - { - setToken(event.target.value); - }} - /> -
- - -
-
- ) : null} ); } diff --git a/packages/plugins-ui/src/plugin-card.tsx b/packages/plugins-ui/src/plugin-card.tsx index 8a239af6f..4d6d2c973 100644 --- a/packages/plugins-ui/src/plugin-card.tsx +++ b/packages/plugins-ui/src/plugin-card.tsx @@ -7,6 +7,7 @@ import { Button } from "@corbits/react-ui"; import type { ResolvedPlugin } from "@corbits/connections/plugins"; +import { oauthStartHref } from "@corbits/settings-ui"; import { pluginIcon, pluginOutcome } from "./plugin-meta"; import { PluginLogo } from "./plugin-logo"; @@ -25,9 +26,11 @@ const PROVENANCE_LABEL: Record<"this-workbench" | "inherited", string> = { }; export function PluginCard({ + tenantId, plugin, onOpen, }: { + readonly tenantId: string; readonly plugin: ResolvedPlugin; readonly onOpen: () => void; }) { @@ -36,6 +39,10 @@ export function PluginCard({ plugin.provenance !== null ? `${STATUS_CAPTION[plugin.status]} · ${PROVENANCE_LABEL[plugin.provenance]}` : STATUS_CAPTION[plugin.status]; + const isDirectOAuthConnect = + plugin.status === "not_connected" && + (plugin.descriptor.authKind === "oauth-pkce" || + plugin.descriptor.authKind === "oauth-code"); return (
{caption} - {plugin.status === "not_connected" ? ( + {isDirectOAuthConnect ? ( + + ) : plugin.status === "not_connected" ? ( + {error !== null ? ( +

+ {error} +

+ ) : null} +
+ ); +} + export function PluginConnectPanel({ tenantId, - plugin, + subject, onClose, onChanged, }: { readonly tenantId: string; - readonly plugin: ResolvedPlugin | null; + readonly subject: PluginPanelSubject | null; readonly onClose: () => void; - readonly onChanged: () => void; + readonly onChanged: (toolCount?: number) => void; }) { - const open = plugin !== null; + const open = subject !== null; + const plugin = subject?.kind === "connector" ? subject.plugin : null; + const preset = subject?.kind === "mcp-preset" ? subject.preset : null; + const toolCount = + subject?.kind === "mcp-preset" ? subject.toolCount : undefined; // CL-6830: probe is tri-state — never fold a failure into `{}`, which // reads as "hosted app absent" and hides one-click connect behind the // not-configured token paste. @@ -211,7 +365,7 @@ export function PluginConnectPanel({ const [oauthProbeKey, setOauthProbeKey] = useState(0); useEffect(() => { - if (!open) return; + if (plugin === null) return; let cancelled = false; setOauthProbe({ status: "loading" }); fetchOAuthConfigured(tenantId) @@ -224,7 +378,7 @@ export function PluginConnectPanel({ return () => { cancelled = true; }; - }, [open, tenantId, oauthProbeKey]); + }, [plugin, tenantId, oauthProbeKey]); const hostedAppAvailable = plugin?.descriptor.oauth !== undefined && @@ -238,19 +392,21 @@ export function PluginConnectPanel({ if (!next) onClose(); }} > - + - {plugin?.descriptor.displayName ?? ""} + + {plugin?.descriptor.displayName ?? preset?.displayName ?? ""} + - {plugin === null - ? "" - : pluginOutcome( + {plugin !== null + ? pluginOutcome( plugin.descriptor.id, plugin.descriptor.displayName, - )} + ) + : (preset?.description ?? "")} - {plugin === null ? null : ( + {plugin !== null ? ( {plugin.status !== "not_connected" ? ( ) : null} - )} + ) : preset !== null ? ( + + + + ) : null}
)} + setOpenPreset(null)} + onChanged={(toolCount) => { + if (openPreset === null) return; + presetCatalog.handleChanged(openPreset.preset.slug, toolCount); + setOpenPreset(null); + }} + /> ); } From 5ee07072e8fc84403437ef57cd786da78c2580ca Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 8 Sep 2026 20:54:26 +0545 Subject: [PATCH 3/6] Refine plugin drawer hierarchy --- .../plugins-ui/src/plugin-connect-panel.tsx | 63 ++++++++++--------- .../test/plugin-connect-panel.test.tsx | 6 ++ 2 files changed, 41 insertions(+), 28 deletions(-) diff --git a/packages/plugins-ui/src/plugin-connect-panel.tsx b/packages/plugins-ui/src/plugin-connect-panel.tsx index 203a469fa..9c7b8ea1b 100644 --- a/packages/plugins-ui/src/plugin-connect-panel.tsx +++ b/packages/plugins-ui/src/plugin-connect-panel.tsx @@ -18,7 +18,6 @@ import { DialogBody, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, Input, @@ -165,9 +164,12 @@ function ConnectedSummary({ } return ( -
-
- +
+
+ {plugin.status === "connected" ? "Connected" : "Needs attention"} @@ -180,10 +182,12 @@ function ConnectedSummary({ creates a connection of your own instead of changing theirs.

) : null} -
- {plugin.provenance === "this-workbench" ? ( + {plugin.provenance === "this-workbench" ? ( +
+

+ Disconnecting removes this plugin's access from the workbench. +

{busy ? "Disconnecting…" : "Disconnect"} - ) : null} -
+
+ ) : null} {error !== null ? (

{error} @@ -257,21 +261,25 @@ function McpPresetPanelContent({ if (preset.connected) { return ( -

- +
+ {toolCount === undefined ? "Connected" : `${toolCount} tool${toolCount === 1 ? "" : "s"}`} - - {busy ? "Disconnecting…" : "Disconnect"} - +
+

+ Disconnecting removes this plugin's access from the workbench. +

+ + {busy ? "Disconnecting…" : "Disconnect"} + +
{error !== null ? (

{error} @@ -392,7 +400,11 @@ export function PluginConnectPanel({ if (!next) onClose(); }} > - + {plugin?.descriptor.displayName ?? preset?.displayName ?? ""} @@ -407,7 +419,7 @@ export function PluginConnectPanel({ {plugin !== null ? ( - + {plugin.status !== "not_connected" ? ( ) : preset !== null ? ( - + ) : null} - - - ); diff --git a/packages/plugins-ui/test/plugin-connect-panel.test.tsx b/packages/plugins-ui/test/plugin-connect-panel.test.tsx index 59b5f78a0..640f0adf1 100644 --- a/packages/plugins-ui/test/plugin-connect-panel.test.tsx +++ b/packages/plugins-ui/test/plugin-connect-panel.test.tsx @@ -176,6 +176,9 @@ describe("PluginConnectPanel", () => { (button) => button.textContent?.includes("Disconnect") === true, ); expect(disconnectButton).not.toBeUndefined(); + expect(disconnectButton?.className).toContain("border-input"); + expect(disconnectButton?.className).not.toContain("bg-destructive"); + expect(container.textContent).not.toContain("Close"); act(() => { disconnectButton?.dispatchEvent( @@ -383,6 +386,9 @@ describe("PluginConnectPanel", () => { const disconnect = [...container.querySelectorAll("button")].find( (button) => button.textContent?.includes("Disconnect") === true, ); + expect(disconnect?.className).toContain("border-input"); + expect(disconnect?.className).not.toContain("bg-destructive"); + expect(container.textContent).not.toContain("Close"); act(() => { disconnect?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); From 15bc873a9802a0ffc0fec7101c423525f7b81c4c Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 8 Sep 2026 21:03:50 +0545 Subject: [PATCH 4/6] Keep focused input borders inside their controls --- apps/web/src/app.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 5b1cacb6a..49e646cb4 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -38,6 +38,13 @@ html { outline-offset: 2px; } +/* Inputs already have a visible control boundary. Color that edge on focus so + the focus treatment reads as one control, not a second outline outside it. */ +[data-slot="input"]:focus-visible { + outline: none; + border-color: var(--accent-rail, var(--primary)); +} + /* Brand type: Red Hat Display is loaded in index.html but nothing applied it — without this the whole app silently renders in the system fallback. Space Mono covers the places code/mono content asks for monospace. */ From b19ca3b8e43dcb39220808affbd48dc3140ddd2f Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 9 Sep 2026 13:50:55 +0545 Subject: [PATCH 5/6] Add plugin drawer lifecycle coverage --- apps/web/test/plugins-page.test.tsx | 1 + .../test/plugin-connect-panel.test.tsx | 4 ++ .../plugins-ui/test/plugins-gallery.test.tsx | 40 +++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/apps/web/test/plugins-page.test.tsx b/apps/web/test/plugins-page.test.tsx index 759fa47a2..7c2ba5fb0 100644 --- a/apps/web/test/plugins-page.test.tsx +++ b/apps/web/test/plugins-page.test.tsx @@ -654,6 +654,7 @@ describe("PluginsRoute", () => { // The delete resolved and `onChanged` fired `reloadPlugins`; its fetch // is now the deferred one above, still pending. + expect(document.body.querySelector('[role="dialog"]')).toBeNull(); expect(el.textContent).not.toContain("Loading plugins…"); expect(el.textContent).toContain("GitHub"); diff --git a/packages/plugins-ui/test/plugin-connect-panel.test.tsx b/packages/plugins-ui/test/plugin-connect-panel.test.tsx index 640f0adf1..e06af2cba 100644 --- a/packages/plugins-ui/test/plugin-connect-panel.test.tsx +++ b/packages/plugins-ui/test/plugin-connect-panel.test.tsx @@ -337,6 +337,10 @@ describe("PluginConnectPanel", () => { const field = container.querySelector( "#mcp-preset-token-github-mcp", ) as HTMLInputElement; + expect( + container.querySelector(`label[for="${field.id}"]`)?.textContent, + ).toContain("Personal access token"); + expect(field.autocomplete).toBe("new-password"); const setter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value", diff --git a/packages/plugins-ui/test/plugins-gallery.test.tsx b/packages/plugins-ui/test/plugins-gallery.test.tsx index ade5aa372..1996b44cb 100644 --- a/packages/plugins-ui/test/plugins-gallery.test.tsx +++ b/packages/plugins-ui/test/plugins-gallery.test.tsx @@ -329,6 +329,46 @@ describe("PluginsGallery", () => { ).not.toBeNull(); }); + test("dismissing a token drawer discards its pasted token before reopening", async () => { + const { container } = await renderGallery(); + const connect = container.querySelector( + '[data-plugin-slug="github-mcp"] [aria-label="Connect GitHub MCP"]', + ) as HTMLButtonElement; + + connect.focus(); + act(() => connect.click()); + const field = document.body.querySelector( + "#mcp-preset-token-github-mcp", + ) as HTMLInputElement; + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set; + await act(async () => { + setter?.call(field, "ghp_unsubmitted"); + field.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(field.value).toBe("ghp_unsubmitted"); + + const close = document.body.querySelector( + '[role="dialog"] [aria-label="Close"]', + ) as HTMLButtonElement; + await act(async () => { + close.click(); + }); + expect(document.body.querySelector('[role="dialog"]')).toBeNull(); + expect(document.activeElement).toBe(connect); + + act(() => connect.click()); + expect( + ( + document.body.querySelector( + "#mcp-preset-token-github-mcp", + ) as HTMLInputElement + ).value, + ).toBe(""); + }); + test("status remains visible as a core field", async () => { const { container } = await renderGallery(); const caption = [...container.querySelectorAll("span")].find( From 269b65cd2cea5fcb732f3818387c85afc1461254 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 9 Sep 2026 13:51:06 +0545 Subject: [PATCH 6/6] Improve plugin drawer input behavior --- apps/web/src/app.css | 8 ++++-- packages/plugins-ui/src/mcp-preset-cards.tsx | 10 +++---- .../plugins-ui/src/plugin-connect-panel.tsx | 4 +-- packages/plugins-ui/src/plugins-gallery.tsx | 28 ++++++++++++++----- 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 49e646cb4..5c96f3c51 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -38,9 +38,11 @@ html { outline-offset: 2px; } -/* Inputs already have a visible control boundary. Color that edge on focus so - the focus treatment reads as one control, not a second outline outside it. */ -[data-slot="input"]:focus-visible { +/* Inputs in side drawers already have a visible control boundary. Color that + edge on focus so the focus treatment reads as one control, not a second + outline outside it. */ +[data-slot="dialog-content"][data-side="right"] + [data-slot="input"]:focus-visible { outline: none; border-color: var(--accent-rail, var(--primary)); } diff --git a/packages/plugins-ui/src/mcp-preset-cards.tsx b/packages/plugins-ui/src/mcp-preset-cards.tsx index f2d1f3ff8..1fc582424 100644 --- a/packages/plugins-ui/src/mcp-preset-cards.tsx +++ b/packages/plugins-ui/src/mcp-preset-cards.tsx @@ -75,7 +75,7 @@ export function McpPresetCard({ readonly preset: McpPreset; readonly toolCount: number | undefined; readonly onChanged: (toolCount?: number) => void; - readonly onOpen: () => void; + readonly onOpen: (trigger: HTMLButtonElement) => void; }) { const [busy, setBusy] = useState(false); const [error, setError] = useState(() => @@ -95,13 +95,13 @@ export function McpPresetCard({ .finally(() => setBusy(false)); } - function handleConnect() { + function handleConnect(trigger: HTMLButtonElement) { if (preset.connectionMode === "oauth") { window.location.href = mcpOAuthStartPath(tenantId, preset.slug); return; } if (preset.connectionMode === "token") { - onOpen(); + onOpen(trigger); return; } submitConnect(); @@ -153,7 +153,7 @@ export function McpPresetCard({ size="sm" variant="ghost" aria-label={`Manage ${preset.displayName}`} - onClick={onOpen} + onClick={(event) => onOpen(event.currentTarget)} > Manage {preset.displayName} @@ -165,7 +165,7 @@ export function McpPresetCard({ variant="ghost" disabled={busy} aria-label={`Connect ${preset.displayName}`} - onClick={handleConnect} + onClick={(event) => handleConnect(event.currentTarget)} > {busy ? "Connecting…" : "Connect"} diff --git a/packages/plugins-ui/src/plugin-connect-panel.tsx b/packages/plugins-ui/src/plugin-connect-panel.tsx index 9c7b8ea1b..915085b69 100644 --- a/packages/plugins-ui/src/plugin-connect-panel.tsx +++ b/packages/plugins-ui/src/plugin-connect-panel.tsx @@ -312,14 +312,14 @@ function McpPresetPanelContent({ className="flex flex-col gap-1.5 text-sm font-medium" htmlFor={tokenFieldId} > - API key + Personal access token { setToken(event.target.value); setError(null); diff --git a/packages/plugins-ui/src/plugins-gallery.tsx b/packages/plugins-ui/src/plugins-gallery.tsx index c043e2901..9dff2f425 100644 --- a/packages/plugins-ui/src/plugins-gallery.tsx +++ b/packages/plugins-ui/src/plugins-gallery.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Lightning } from "@corbits/icons"; import { EmptyState, FilterChip, Tabs } from "@corbits/react-ui"; @@ -133,6 +133,7 @@ function PluginCatalogPanel({ readonly onOpenPreset: ( preset: McpPreset, toolCount: number | undefined, + trigger: HTMLButtonElement, ) => void; readonly onOpenPlugin: (plugin: ResolvedPlugin) => void; }) { @@ -181,8 +182,8 @@ function PluginCatalogPanel({ preset={entry.preset} toolCount={toolCounts.get(entry.id)} onChanged={(toolCount) => onPresetChanged(entry.id, toolCount)} - onOpen={() => - onOpenPreset(entry.preset, toolCounts.get(entry.id)) + onOpen={(trigger) => + onOpenPreset(entry.preset, toolCounts.get(entry.id), trigger) } /> ) : ( @@ -296,9 +297,22 @@ export function PluginsGallery({ const [openPreset, setOpenPreset] = useState<{ readonly preset: McpPreset; readonly toolCount: number | undefined; + readonly trigger: HTMLButtonElement; } | null>(null); + const presetFocusTrigger = useRef(null); const presetCatalog = useMcpPresetCatalog(tenantId); + useEffect(() => { + if (openPreset !== null) return; + presetFocusTrigger.current?.focus(); + presetFocusTrigger.current = null; + }, [openPreset]); + + function closePreset() { + presetFocusTrigger.current = openPreset?.trigger ?? null; + setOpenPreset(null); + } + const nativeEntries = useMemo( () => plugins @@ -398,8 +412,8 @@ export function PluginsGallery({ onFilterChange={setActiveFilter} toolCounts={presetCatalog.toolCounts} onPresetChanged={presetCatalog.handleChanged} - onOpenPreset={(preset, toolCount) => - setOpenPreset({ preset, toolCount }) + onOpenPreset={(preset, toolCount, trigger) => + setOpenPreset({ preset, toolCount, trigger }) } onOpenPlugin={onOpenPlugin} /> @@ -427,11 +441,11 @@ export function PluginsGallery({ toolCount: openPreset.toolCount, } } - onClose={() => setOpenPreset(null)} + onClose={closePreset} onChanged={(toolCount) => { if (openPreset === null) return; presetCatalog.handleChanged(openPreset.preset.slug, toolCount); - setOpenPreset(null); + closePreset(); }} />