feat(settings): drive providers panel from pi-mono catalog

ProvidersPanel reads all 26 CONNECTOR_CATALOG entries and groups them by
auth kind (OAuth / API key / Azure / AWS / Vertex). ProviderRow
dispatches to the matching dedicated form component per auth kind. AWS,
Azure, Vertex forms rewritten with Tailwind classes and a shared field
style module so the Settings pane renders with the same aesthetic as the
rest of the app.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-17 02:19:56 -04:00
1 parent 7fdd3c16bf
commit 4a120f4fc6
8 files changed
+842 -60

No files matched your search

@@ -0,0 +1,93 @@
import { useState } from "react";
const desktop = (typeof window !== "undefined" ? window.agentCompany : null) as
| (Window["agentCompany"] | null);
interface Props {
connectorId: string;
defaultEnvVar?: string;
onSaved?: () => void;
}
export function ApiKeyForm({ connectorId, defaultEnvVar, onSaved }: Props) {
const [source, setSource] = useState<"literal" | "env">("literal");
const [value, setValue] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const isEnv = source === "env";
const canSave = value.trim().length > 0 && !busy && Boolean(desktop);
const handleSubmit = async () => {
if (!desktop || !canSave) return;
setBusy(true);
setError(null);
const result = await desktop.saveCredential({
connectorId,
payload: {
kind: "api_key",
value: { source, value: value.trim() },
},
});
setBusy(false);
if (!result.ok) {
setError(result.error.message);
return;
}
setValue("");
onSaved?.();
};
const toggleSource = () => {
setSource(isEnv ? "literal" : "env");
setValue("");
setError(null);
};
return (
<form
onSubmit={(event) => {
event.preventDefault();
void handleSubmit();
}}
className="space-y-3"
>
<input
type={isEnv ? "text" : "password"}
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={isEnv ? (defaultEnvVar ?? "PROVIDER_API_KEY") : "Paste your API key"}
autoComplete="off"
spellCheck={false}
disabled={busy}
autoFocus
aria-label={isEnv ? "Environment variable name" : "API key"}
className="block w-full rounded-md border border-border bg-background px-3 py-2.5 font-mono text-[12px] tracking-tight text-foreground outline-none transition-colors placeholder:font-sans placeholder:tracking-normal placeholder:text-muted-foreground/50 focus:border-foreground/50 disabled:opacity-50"
/>
{error ? (
<div role="alert" className="text-[11px] text-red-400">
{error}
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={toggleSource}
disabled={busy}
className="text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
>
{isEnv ? "Paste key directly" : "Use environment variable instead"}
</button>
<button
type="submit"
disabled={!canSave}
className="rounded px-3 py-1.5 text-[11px] font-medium bg-foreground text-background transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30"
>
{busy ? "Saving…" : "Save"}
</button>
</div>
</form>
);
}
@@ -0,0 +1,225 @@
import { useState } from "react";
import {
FIELD_INPUT,
FIELD_LABEL,
FIELD_SELECT,
FORM_BUTTON,
FORM_ERROR,
FORM_FIELDSET,
} from "./form-shared";
interface Props {
connectorId: string;
onSaved?: () => void;
}
type AuthMode = "profile" | "iam" | "bearer";
export function AwsForm({ connectorId, onSaved }: Props) {
const [authMode, setAuthMode] = useState<AuthMode>("profile");
const [region, setRegion] = useState("us-east-1");
const [awsProfile, setAwsProfile] = useState("");
const [accessKeyId, setAccessKeyId] = useState("");
const [secretAccessKey, setSecretAccessKey] = useState("");
const [sessionToken, setSessionToken] = useState("");
const [bearerToken, setBearerToken] = useState("");
const [forceCache, setForceCache] = useState(false);
const [endpointUrl, setEndpointUrl] = useState("");
const [skipAuth, setSkipAuth] = useState(false);
const [forceHttp1, setForceHttp1] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
const desktop = window.agentCompany;
if (!desktop) return;
if (authMode === "profile" && awsProfile.trim().length === 0) {
setError("AWS profile name required.");
return;
}
if (
authMode === "iam" &&
(accessKeyId.trim().length === 0 || secretAccessKey.trim().length === 0)
) {
setError("Access key id and secret access key required for IAM auth.");
return;
}
if (authMode === "bearer" && bearerToken.trim().length === 0) {
setError("Bearer token required.");
return;
}
setBusy(true);
setError(null);
const result = await desktop.saveCredential({
connectorId,
payload: {
kind: "aws",
value: {
authMode,
region: region.trim() || "us-east-1",
awsProfile: authMode === "profile" ? awsProfile.trim() : undefined,
accessKeyId: authMode === "iam" ? accessKeyId.trim() : undefined,
secretAccessKey: authMode === "iam" ? secretAccessKey.trim() : undefined,
sessionToken:
authMode === "iam" && sessionToken.trim() ? sessionToken.trim() : undefined,
bearerToken: authMode === "bearer" ? bearerToken.trim() : undefined,
forceCache,
endpointUrl: endpointUrl.trim() || undefined,
skipAuth,
forceHttp1,
},
},
});
setBusy(false);
if (!result.ok) {
setError(result.error.message);
return;
}
onSaved?.();
};
return (
<form
onSubmit={(event) => {
event.preventDefault();
void handleSubmit();
}}
className={FORM_FIELDSET}
>
<div>
<label className={FIELD_LABEL}>Auth mode</label>
<select
value={authMode}
onChange={(event) => setAuthMode(event.target.value as AuthMode)}
disabled={busy}
className={FIELD_SELECT}
>
<option value="profile">AWS profile (~/.aws/credentials)</option>
<option value="iam">IAM access key + secret</option>
<option value="bearer">Bedrock bearer token</option>
</select>
</div>
<div>
<label className={FIELD_LABEL}>AWS region</label>
<input
type="text"
value={region}
onChange={(event) => setRegion(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
placeholder="us-east-1"
/>
</div>
{authMode === "profile" ? (
<div>
<label className={FIELD_LABEL}>Profile name</label>
<input
type="text"
value={awsProfile}
onChange={(event) => setAwsProfile(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
placeholder="default"
/>
</div>
) : null}
{authMode === "iam" ? (
<>
<div>
<label className={FIELD_LABEL}>Access key id</label>
<input
type="text"
value={accessKeyId}
onChange={(event) => setAccessKeyId(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
autoComplete="off"
/>
</div>
<div>
<label className={FIELD_LABEL}>Secret access key</label>
<input
type="password"
value={secretAccessKey}
onChange={(event) => setSecretAccessKey(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
autoComplete="off"
/>
</div>
<div>
<label className={FIELD_LABEL}>Session token (optional)</label>
<input
type="password"
value={sessionToken}
onChange={(event) => setSessionToken(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
autoComplete="off"
/>
</div>
</>
) : null}
{authMode === "bearer" ? (
<div>
<label className={FIELD_LABEL}>Bearer token</label>
<input
type="password"
value={bearerToken}
onChange={(event) => setBearerToken(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
autoComplete="off"
/>
</div>
) : null}
<details className="rounded-md border border-border/50 px-3 py-2 text-[12px]">
<summary className="cursor-pointer select-none text-muted-foreground">Advanced</summary>
<div className="mt-3 space-y-2">
<label className="flex items-center gap-2 text-[12px] text-foreground">
<input
type="checkbox"
checked={forceCache}
onChange={(event) => setForceCache(event.target.checked)}
disabled={busy}
/>
Force prompt cache (AWS_BEDROCK_FORCE_CACHE)
</label>
<div>
<label className={FIELD_LABEL}>Endpoint URL (optional)</label>
<input
type="url"
value={endpointUrl}
onChange={(event) => setEndpointUrl(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
placeholder="https://bedrock-runtime.your-region.amazonaws.com"
/>
</div>
<label className="flex items-center gap-2 text-[12px] text-foreground">
<input
type="checkbox"
checked={skipAuth}
onChange={(event) => setSkipAuth(event.target.checked)}
disabled={busy}
/>
Skip auth (AWS_BEDROCK_SKIP_AUTH)
</label>
<label className="flex items-center gap-2 text-[12px] text-foreground">
<input
type="checkbox"
checked={forceHttp1}
onChange={(event) => setForceHttp1(event.target.checked)}
disabled={busy}
/>
Force HTTP/1.1 (AWS_BEDROCK_FORCE_HTTP1)
</label>
</div>
</details>
{error ? <div className={FORM_ERROR}>{error}</div> : null}
<button type="submit" disabled={busy} className={FORM_BUTTON}>
{busy ? "Saving…" : "Save"}
</button>
</form>
);
}
@@ -0,0 +1,181 @@
import { useState } from "react";
import {
FIELD_INPUT,
FIELD_LABEL,
FORM_BUTTON,
FORM_BUTTON_SECONDARY,
FORM_ERROR,
FORM_FIELDSET,
} from "./form-shared";
interface Props {
connectorId: string;
onSaved?: () => void;
}
interface DeploymentEntry {
modelId: string;
deploymentName: string;
}
export function AzureForm({ connectorId, onSaved }: Props) {
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [resourceName, setResourceName] = useState("");
const [apiVersion, setApiVersion] = useState("");
const [deployments, setDeployments] = useState<DeploymentEntry[]>([
{ modelId: "", deploymentName: "" },
]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
const desktop = window.agentCompany;
if (!desktop) return;
if (apiKey.trim().length === 0) {
setError("API key required.");
return;
}
setBusy(true);
setError(null);
const deploymentMap = Object.fromEntries(
deployments
.map((entry) => [entry.modelId.trim(), entry.deploymentName.trim()])
.filter(([k, v]) => k.length > 0 && v.length > 0),
);
const result = await desktop.saveCredential({
connectorId,
payload: {
kind: "azure",
value: {
apiKey: apiKey.trim(),
baseUrl: baseUrl.trim() || undefined,
resourceName: resourceName.trim() || undefined,
apiVersion: apiVersion.trim() || undefined,
deploymentMap: Object.keys(deploymentMap).length > 0 ? deploymentMap : undefined,
},
},
});
setBusy(false);
if (!result.ok) {
setError(result.error.message);
return;
}
onSaved?.();
};
return (
<form
onSubmit={(event) => {
event.preventDefault();
void handleSubmit();
}}
className={FORM_FIELDSET}
>
<div>
<label className={FIELD_LABEL}>API key</label>
<input
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
autoComplete="off"
/>
</div>
<div>
<label className={FIELD_LABEL}>Base URL (optional)</label>
<input
type="url"
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
placeholder="https://YOUR-RESOURCE.openai.azure.com/"
/>
</div>
<div>
<label className={FIELD_LABEL}>Resource name (optional)</label>
<input
type="text"
value={resourceName}
onChange={(event) => setResourceName(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
/>
</div>
<div>
<label className={FIELD_LABEL}>API version (optional)</label>
<input
type="text"
value={apiVersion}
onChange={(event) => setApiVersion(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
placeholder="2024-08-01-preview"
/>
</div>
<fieldset className="rounded-md border border-border/50 px-3 py-2">
<legend className="px-1 text-[11px] font-medium text-muted-foreground">
Deployment map (model id → deployment name)
</legend>
<div className="space-y-2">
{deployments.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<input
type="text"
value={entry.modelId}
placeholder="gpt-4o"
disabled={busy}
className={FIELD_INPUT}
onChange={(event) =>
setDeployments((prev) =>
prev.map((p, i) => (i === index ? { ...p, modelId: event.target.value } : p)),
)
}
/>
<input
type="text"
value={entry.deploymentName}
placeholder="my-gpt4-deployment"
disabled={busy}
className={FIELD_INPUT}
onChange={(event) =>
setDeployments((prev) =>
prev.map((p, i) =>
i === index ? { ...p, deploymentName: event.target.value } : p,
),
)
}
/>
<button
type="button"
disabled={busy}
onClick={() =>
setDeployments((prev) => prev.filter((_, i) => i !== index))
}
className={FORM_BUTTON_SECONDARY}
>
Remove
</button>
</div>
))}
<button
type="button"
disabled={busy}
onClick={() =>
setDeployments((prev) => [...prev, { modelId: "", deploymentName: "" }])
}
className={FORM_BUTTON_SECONDARY}
>
Add mapping
</button>
</div>
</fieldset>
{error ? <div className={FORM_ERROR}>{error}</div> : null}
<button type="submit" disabled={busy} className={FORM_BUTTON}>
{busy ? "Saving…" : "Save"}
</button>
</form>
);
}
@@ -0,0 +1,115 @@
import { useState } from "react";
import {
FIELD_INPUT,
FIELD_LABEL,
FORM_BUTTON,
FORM_BUTTON_SECONDARY,
FORM_ERROR,
FORM_FIELDSET,
} from "./form-shared";
interface Props {
connectorId: string;
onSaved?: () => void;
}
export function VertexForm({ connectorId, onSaved }: Props) {
const [project, setProject] = useState("");
const [location, setLocation] = useState("");
const [credentialsPath, setCredentialsPath] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
const desktop = window.agentCompany;
if (!desktop) return;
if (project.trim().length === 0 || location.trim().length === 0) {
setError("Project and location are required.");
return;
}
setBusy(true);
setError(null);
const result = await desktop.saveCredential({
connectorId,
payload: {
kind: "gcp_adc",
value: {
project: project.trim(),
location: location.trim(),
credentialsPath: credentialsPath.trim() || undefined,
},
},
});
setBusy(false);
if (!result.ok) {
setError(result.error.message);
return;
}
onSaved?.();
};
const pickCredentialsFile = async () => {
const desktop = window.agentCompany;
if (!desktop) return;
const result = await desktop.pickDirectory();
if (result.ok && result.data) setCredentialsPath(result.data);
};
return (
<form
onSubmit={(event) => {
event.preventDefault();
void handleSubmit();
}}
className={FORM_FIELDSET}
>
<div>
<label className={FIELD_LABEL}>GCP project id</label>
<input
type="text"
value={project}
onChange={(event) => setProject(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
placeholder="my-gcp-project"
/>
</div>
<div>
<label className={FIELD_LABEL}>Region / location</label>
<input
type="text"
value={location}
onChange={(event) => setLocation(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
placeholder="us-central1"
/>
</div>
<div>
<label className={FIELD_LABEL}>Credentials JSON path (optional — leave blank for ADC)</label>
<div className="mt-1 flex gap-2">
<input
type="text"
value={credentialsPath}
onChange={(event) => setCredentialsPath(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
placeholder="/path/to/service-account.json"
/>
<button
type="button"
onClick={() => void pickCredentialsFile()}
disabled={busy}
className={FORM_BUTTON_SECONDARY}
>
Browse
</button>
</div>
</div>
{error ? <div className={FORM_ERROR}>{error}</div> : null}
<button type="submit" disabled={busy} className={FORM_BUTTON}>
{busy ? "Saving…" : "Save"}
</button>
</form>
);
}
@@ -0,0 +1,18 @@
export const FIELD_LABEL =
"block text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground";
export const FIELD_INPUT =
"mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/60 focus:border-foreground/40";
export const FIELD_SELECT = FIELD_INPUT + " appearance-none";
export const FORM_BUTTON =
"rounded px-3 py-1.5 text-[11px] font-medium bg-foreground text-background transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30";
export const FORM_BUTTON_SECONDARY =
"rounded px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40";
export const FORM_FIELDSET = "space-y-3";
export const FORM_ERROR =
"rounded-md border border-[color:var(--destructive)] bg-destructive-muted px-3 py-2 text-[11px] text-destructive";
@@ -0,0 +1,182 @@
import { useState } from "react";
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
import type { ConnectorCatalogEntry } from "@shared/connector-catalog";
import { ApiKeyForm } from "../forms/ApiKeyForm";
import { AzureForm } from "../forms/AzureForm";
import { AwsForm } from "../forms/AwsForm";
import { VertexForm } from "../forms/VertexForm";
interface Props {
entry: ConnectorCatalogEntry;
credential: CredentialSummary | null;
}
const KIND_LABEL: Record<ConnectorCatalogEntry["auth"]["type"], string> = {
oauth: "OAuth",
api_key: "API key",
azure: "Azure",
aws: "AWS",
gcp_adc: "Vertex (ADC)",
};
const STATUS_STYLE: Record<string, { dot: string; text: string }> = {
active: { dot: "bg-emerald-400", text: "text-emerald-400" },
expired: { dot: "bg-amber-400", text: "text-amber-400" },
error: { dot: "bg-red-400", text: "text-red-400" },
not_configured: { dot: "bg-muted-foreground/30", text: "text-muted-foreground" },
revoked: { dot: "bg-muted-foreground/30", text: "text-muted-foreground" },
};
const STATUS_LABEL: Record<string, string> = {
active: "Connected",
expired: "Expired",
error: "Error",
not_configured: "Not configured",
revoked: "Revoked",
};
const ACTION_BASE =
"rounded px-2.5 py-1 text-[11px] font-medium transition-colors disabled:opacity-40";
const ACTION_PRIMARY = ACTION_BASE + " bg-foreground text-background hover:opacity-90";
const ACTION_SECONDARY = ACTION_BASE + " text-muted-foreground hover:text-foreground";
export function ProviderRow({ entry, credential }: Props) {
const [busy, setBusy] = useState<"login" | "remove" | "test" | null>(null);
const [feedback, setFeedback] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const status = credential?.status ?? "not_configured";
const statusStyle = STATUS_STYLE[status] ?? STATUS_STYLE.not_configured;
const statusLabel = STATUS_LABEL[status] ?? status;
const hasCredential = credential !== null;
const handleOAuthLogin = async () => {
const desktop = window.agentCompany;
if (!desktop) return;
setBusy("login");
setFeedback(null);
const result = await desktop.startOAuth({ connectorId: entry.id });
setBusy(null);
if (!result.ok) setFeedback(result.error.message);
};
const handleRemove = async () => {
const desktop = window.agentCompany;
if (!desktop) return;
setBusy("remove");
const result = await desktop.removeCredential({ connectorId: entry.id });
setBusy(null);
if (!result.ok) setFeedback(result.error.message);
};
const handleTest = async () => {
const desktop = window.agentCompany;
if (!desktop) return;
setBusy("test");
setFeedback(null);
const result = await desktop.testCredential({ connectorId: entry.id });
setBusy(null);
if (!result.ok) {
setFeedback(result.error.message);
return;
}
setFeedback(
result.data.ok ? `OK — ${result.data.message}` : `Failed — ${result.data.message}`,
);
};
const renderForm = () => {
if (!editing || entry.auth.type === "oauth") return null;
const onSaved = () => {
setEditing(false);
setFeedback("Saved.");
};
const apiKeyEnv = entry.auth.type === "api_key" ? entry.auth.envKey : undefined;
if (entry.auth.type === "api_key")
return <ApiKeyForm connectorId={entry.id} defaultEnvVar={apiKeyEnv} onSaved={onSaved} />;
if (entry.auth.type === "azure")
return <AzureForm connectorId={entry.id} onSaved={onSaved} />;
if (entry.auth.type === "aws")
return <AwsForm connectorId={entry.id} onSaved={onSaved} />;
if (entry.auth.type === "gcp_adc")
return <VertexForm connectorId={entry.id} onSaved={onSaved} />;
return null;
};
const disabled = busy !== null;
const isOAuth = entry.auth.type === "oauth";
return (
<li className="px-4 py-3 transition-colors hover:bg-muted/30">
<div className="flex items-center gap-4">
<span
aria-label={`status: ${statusLabel}`}
className={`block h-2 w-2 flex-shrink-0 rounded-full ${statusStyle.dot}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<div className="truncate text-[13px] font-semibold text-foreground">
{entry.label}
</div>
<div className="text-[10px] uppercase tracking-[0.1em] text-muted-foreground/70">
{KIND_LABEL[entry.auth.type]}
</div>
</div>
<div className={`truncate text-[11px] ${statusStyle.text}`}>
{hasCredential ? (credential.displayHint ?? statusLabel) : statusLabel}
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-1">
{isOAuth ? (
<button
type="button"
disabled={disabled}
onClick={() => void handleOAuthLogin()}
className={hasCredential ? ACTION_SECONDARY : ACTION_PRIMARY}
>
{busy === "login" ? "Opening…" : hasCredential ? "Re-login" : "Login"}
</button>
) : (
<button
type="button"
disabled={disabled}
onClick={() => setEditing((current) => !current)}
className={hasCredential || editing ? ACTION_SECONDARY : ACTION_PRIMARY}
>
{editing ? "Close" : hasCredential ? "Edit" : "Configure"}
</button>
)}
{hasCredential ? (
<button
type="button"
disabled={disabled}
onClick={() => void handleTest()}
className={ACTION_SECONDARY}
>
{busy === "test" ? "Testing…" : "Test"}
</button>
) : null}
{hasCredential ? (
<button
type="button"
disabled={disabled}
onClick={() => void handleRemove()}
className={ACTION_SECONDARY}
>
{busy === "remove" ? "Removing…" : "Remove"}
</button>
) : null}
</div>
</div>
{feedback ? (
<div className="mt-2 pl-6 text-[11px] text-muted-foreground">{feedback}</div>
) : null}
{editing && !isOAuth ? (
<div className="mt-3 rounded-md border border-border bg-background/60 p-3">
{renderForm()}
</div>
) : null}
</li>
);
}
@@ -1,56 +1,20 @@
import { useEffect, useMemo, useState } from "react";
import { MagnifyingGlass } from "../../ui/icons";
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
import {
CONNECTOR_CATALOG,
type ConnectorAuthKind,
type ConnectorCatalogEntry,
} from "@shared/connector-catalog";
import { ProviderRow } from "./ProviderRow";
const desktop = (typeof window !== "undefined" ? window.agentCompany : null) as
| (Window["agentCompany"] | null);
/** Provider catalog mirrored from the main process for renderer-only use.
* Keep in sync with src/main/connector-definitions.ts when the catalog grows.
* Each entry is referenced by its connectorId in IPC traffic. */
export interface ProvidersPanelEntry {
id: string;
label: string;
kind: "oauth" | "api_key" | "azure" | "aws" | "gcp_adc";
}
const DEFAULT_CATALOG: ProvidersPanelEntry[] = [
{ id: "claude-pro", label: "Claude Pro / Max", kind: "oauth" },
{ id: "chatgpt-codex", label: "ChatGPT Plus / Pro (Codex)", kind: "oauth" },
{ id: "github-copilot", label: "GitHub Copilot", kind: "oauth" },
{ id: "gemini-cli", label: "Google Gemini CLI", kind: "oauth" },
{ id: "antigravity", label: "Google Antigravity", kind: "oauth" },
{ id: "anthropic", label: "Anthropic API", kind: "api_key" },
{ id: "openai", label: "OpenAI API", kind: "api_key" },
{ id: "google", label: "Google Gemini API", kind: "api_key" },
{ id: "mistral", label: "Mistral", kind: "api_key" },
{ id: "groq", label: "Groq", kind: "api_key" },
{ id: "cerebras", label: "Cerebras", kind: "api_key" },
{ id: "xai", label: "xAI", kind: "api_key" },
{ id: "openrouter", label: "OpenRouter", kind: "api_key" },
{ id: "vercel-ai-gateway", label: "Vercel AI Gateway", kind: "api_key" },
{ id: "zai", label: "ZAI", kind: "api_key" },
{ id: "opencode", label: "OpenCode Zen", kind: "api_key" },
{ id: "opencode-go", label: "OpenCode Go", kind: "api_key" },
{ id: "huggingface", label: "Hugging Face", kind: "api_key" },
{ id: "kimi-coding", label: "Kimi For Coding", kind: "api_key" },
{ id: "minimax", label: "MiniMax", kind: "api_key" },
{ id: "minimax-cn", label: "MiniMax (China)", kind: "api_key" },
{ id: "fireworks", label: "Fireworks AI", kind: "api_key" },
{ id: "fireworks-firepass", label: "Fireworks Fire Pass", kind: "api_key" },
{ id: "azure-openai", label: "Azure OpenAI", kind: "azure" },
{ id: "amazon-bedrock", label: "AWS Bedrock", kind: "aws" },
{ id: "google-vertex", label: "Google Vertex AI", kind: "gcp_adc" },
];
const FILTERS: Array<{ id: "all" | "configured" | "available"; label: string }> = [
{ id: "all", label: "All" },
{ id: "configured", label: "Connected" },
{ id: "available", label: "Available" },
];
const GROUP_ORDER: Array<{ id: ProvidersPanelEntry["kind"]; label: string }> = [
const GROUP_ORDER: Array<{ id: ConnectorAuthKind; label: string }> = [
{ id: "oauth", label: "Sign in" },
{ id: "api_key", label: "API key" },
{ id: "azure", label: "Azure" },
@@ -58,29 +22,29 @@ const GROUP_ORDER: Array<{ id: ProvidersPanelEntry["kind"]; label: string }> = [
{ id: "gcp_adc", label: "Google Cloud" },
];
interface Props {
catalog?: ProvidersPanelEntry[];
interface ProvidersPanelProps {
catalog?: ConnectorCatalogEntry[];
}
export function ProvidersPanel({ catalog }: Props) {
const entries = catalog ?? DEFAULT_CATALOG;
export function ProvidersPanel({ catalog }: ProvidersPanelProps = {}) {
const entries = catalog ?? CONNECTOR_CATALOG;
const [credentials, setCredentials] = useState<Map<string, CredentialSummary>>(new Map());
const [filter, setFilter] = useState<"all" | "configured" | "available">("all");
const [search, setSearch] = useState("");
useEffect(() => {
const desktop = window.agentCompany;
if (!desktop) return;
let cancelled = false;
void desktop.listCredentials().then((result) => {
const refresh = async () => {
const result = await desktop.listCredentials();
if (cancelled || !result.ok) return;
setCredentials(new Map(result.data.map((row) => [row.connectorId, row])));
});
};
void refresh();
const unsubscribe = desktop.subscribe((event) => {
if (event.type !== "credentials-changed") return;
void desktop.listCredentials().then((result) => {
if (!result.ok) return;
setCredentials(new Map(result.data.map((row) => [row.connectorId, row])));
});
void refresh();
});
return () => {
cancelled = true;
@@ -89,24 +53,26 @@ export function ProvidersPanel({ catalog }: Props) {
}, []);
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase();
return entries.filter((entry) => {
const hasCredential = credentials.has(entry.id);
if (filter === "configured" && !hasCredential) return false;
if (filter === "available" && hasCredential) return false;
if (search.trim()) {
const needle = search.trim().toLowerCase();
return entry.label.toLowerCase().includes(needle) || entry.id.includes(needle);
}
return true;
if (needle.length === 0) return true;
return (
entry.label.toLowerCase().includes(needle) ||
entry.id.includes(needle) ||
entry.provider.toLowerCase().includes(needle)
);
});
}, [entries, credentials, filter, search]);
const grouped = useMemo(() => {
const byKind = new Map<ProvidersPanelEntry["kind"], ProvidersPanelEntry[]>();
const byKind = new Map<ConnectorAuthKind, ConnectorCatalogEntry[]>();
for (const entry of filtered) {
const list = byKind.get(entry.kind) ?? [];
const list = byKind.get(entry.auth.type) ?? [];
list.push(entry);
byKind.set(entry.kind, list);
byKind.set(entry.auth.type, list);
}
return GROUP_ORDER.map((group) => ({
...group,
@@ -0,0 +1,2 @@
export { ProvidersPanel } from "./ProvidersPanel";
export { ProviderRow } from "./ProviderRow";