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
9 changes: 9 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ html {
outline-offset: 2px;
}

/* 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));
}

/* 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. */
Expand Down
12 changes: 8 additions & 4 deletions apps/web/src/pages/plugins-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -81,7 +82,7 @@ export function PluginsRoute({
const [skillsState, setSkillsState] = useState<SkillsState>({
status: "loading",
});
const [openPlugin, setOpenPlugin] = useState<ResolvedPlugin | null>(null);
const [openPlugin, setOpenPlugin] = useState<PluginPanelSubject | null>(null);
const [createSkillOpen, setCreateSkillOpen] = useState(false);
const [activeTab, setActiveTab] = useState<PluginsGalleryTab>("plugins");
const [galleryQuery, setGalleryQuery] = useState("");
Expand All @@ -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);
}, []);

Expand Down Expand Up @@ -373,9 +374,12 @@ export function PluginsRoute({
</PageShell>
<PluginConnectPanel
tenantId={tenantId}
plugin={openPlugin}
subject={openPlugin}
onClose={() => setOpenPlugin(null)}
onChanged={reloadPlugins}
onChanged={() => {
reloadPlugins();
setOpenPlugin(null);
}}
/>
<CreateSkillDialog
open={createSkillOpen}
Expand Down
1 change: 1 addition & 0 deletions apps/web/test/plugins-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
5 changes: 4 additions & 1 deletion packages/plugins-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ export { PluginCard } from "./plugin-card";
export { SkillCard } from "./skill-card";
export type { SkillCardData } from "./skill-card";
export { InstalledStrip } from "./installed-strip";
export { PluginConnectPanel } from "./plugin-connect-panel";
export {
PluginConnectPanel,
type PluginPanelSubject,
} from "./plugin-connect-panel";
export { McpServersSection } from "./mcp-servers-section";

export { PLUGINS_STRINGS } from "./strings";
Expand Down
114 changes: 17 additions & 97 deletions packages/plugins-ui/src/mcp-preset-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// connected custom servers share the same server-side store.

import { reportError } from "@corbits/error-sink";
import { Button, ConfirmButton, Input, toast } from "@corbits/react-ui";
import { Button, toast } from "@corbits/react-ui";
import {
CONNECTOR_REGISTRY,
MCP_PRESETS,
Expand All @@ -12,13 +12,11 @@ import { useEffect, useState } from "react";

import {
connectMcpPreset,
disconnectMcpServer,
listMcpPresets,
mcpOAuthStartPath,
type McpPreset,
} from "./mcp-servers-api";
import { PluginLogo } from "./plugin-logo";
import { PLUGINS_STRINGS } from "./strings";

function messageOf(cause: unknown): string {
return cause instanceof Error ? cause.message : String(cause);
Expand Down Expand Up @@ -71,57 +69,42 @@ export function McpPresetCard({
preset,
toolCount,
onChanged,
onOpen,
}: {
readonly tenantId: string;
readonly preset: McpPreset;
readonly toolCount: number | undefined;
readonly onChanged: (toolCount?: number) => void;
readonly onOpen: (trigger: HTMLButtonElement) => void;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(() =>
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)))
.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") {
setTokenFieldOpen(true);
onOpen(trigger);
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(
Expand All @@ -137,8 +120,6 @@ export function McpPresetCard({
: `${toolCount} tool${toolCount === 1 ? "" : "s"}`
: "Not connected";

const tokenFieldId = `mcp-preset-token-${preset.slug}`;

return (
<div
className="min-w-0 px-2 py-2.5"
Expand Down Expand Up @@ -167,91 +148,30 @@ export function McpPresetCard({
<div className="flex flex-none items-center gap-2">
<span className="text-xs text-muted-foreground">{status}</span>
{preset.connected ? (
<ConfirmButton
variant="ghost"
<Button
type="button"
size="sm"
confirmLabel={
<>
Disconnect
<span className="sr-only"> {preset.displayName}</span>
</>
}
disabled={busy}
onConfirm={handleDisconnect}
variant="ghost"
aria-label={`Manage ${preset.displayName}`}
onClick={(event) => onOpen(event.currentTarget)}
>
{busy ? "Disconnecting…" : "Manage"}
Manage
<span className="sr-only"> {preset.displayName}</span>
</ConfirmButton>
) : tokenFieldOpen ? null : (
</Button>
) : (
<Button
type="button"
size="sm"
variant="ghost"
disabled={busy}
aria-label={`Connect ${preset.displayName}`}
onClick={handleConnect}
onClick={(event) => handleConnect(event.currentTarget)}
>
{busy ? "Connecting…" : "Connect"}
</Button>
)}
</div>
</div>
{tokenFieldOpen && !preset.connected ? (
<div className="mt-2 flex flex-col gap-2 pl-11">
<ol className="list-decimal space-y-1 pl-4 text-xs text-muted-foreground">
{(preset.tokenSteps ?? []).map((step) => (
<li key={step}>{step}</li>
))}
</ol>
<a
href={preset.docsUrl}
target="_blank"
rel="noreferrer"
className="text-xs underline underline-offset-2"
>
Create your token
</a>
<label className="sr-only" htmlFor={tokenFieldId}>
{`${preset.displayName} access token`}
</label>
<Input
id={tokenFieldId}
type="password"
value={token}
placeholder="Paste your access token"
disabled={busy}
onChange={(event) => {
setToken(event.target.value);
}}
/>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
disabled={busy || token.trim() === ""}
aria-label={`Connect ${preset.displayName}`}
onClick={() => {
submitConnect(token.trim());
}}
>
{busy ? "Connecting…" : "Connect"}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
disabled={busy}
onClick={() => {
setTokenFieldOpen(false);
setToken("");
setError(null);
}}
>
Cancel
</Button>
</div>
</div>
) : null}
</div>
);
}
Expand Down
18 changes: 17 additions & 1 deletion packages/plugins-ui/src/plugin-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
}) {
Expand All @@ -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 (
<div
Expand All @@ -58,7 +65,16 @@ export function PluginCard({
</div>
<div className="flex flex-none items-center gap-2">
<span className="text-xs text-muted-foreground">{caption}</span>
{plugin.status === "not_connected" ? (
{isDirectOAuthConnect ? (
<Button size="sm" variant="ghost" asChild>
<a
href={oauthStartHref(tenantId, plugin.descriptor.id, "/plugins")}
aria-label={`Connect ${plugin.descriptor.displayName}`}
>
Connect
</a>
</Button>
) : plugin.status === "not_connected" ? (
<Button
type="button"
size="sm"
Expand Down
Loading
Loading