feat(renderer): rewrite providers UI on pi-mono catalog shape

Onboarding ProvidersStep now reads the 26-entry CONNECTOR_CATALOG and
renders featured connectors (claude-pro, openai-codex, anthropic, openai,
amazon-bedrock) up front with a collapsible advanced list for the
remaining 21. Each auth kind gets a dedicated card component: OAuth
button flow, inline API-key form, and a cloud deep-link to Settings.
Delete legacy Claude/Codex/Gemini hardcoded cards and the compatible-API
slot rotation. Swap ModelManagement Settings panel for the new
ProvidersPanel driven by credentialsList IPC.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-17 02:16:35 -04:00
1 parent 6a25379995
commit 7fdd3c16bf
17 files changed
+692 -1422

No files matched your search

@@ -1,243 +1,128 @@
import { useState } from "react";
import { ArrowRight, FloppyDisk, SignIn } from "../ui/icons";
import { useEffect, useMemo, useState } from "react";
import { ArrowRight, CaretDown, CaretRight } from "../ui/icons";
import { ActionButton } from "../ui";
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
import {
CompatProviderCard,
InstallBlock,
ProviderCard,
StatusBadge,
INPUT_CLS,
} from "./ProvidersStepShared";
CONNECTOR_CATALOG,
listFeaturedOnboardingConnectors,
} from "@shared/connector-catalog";
import { OnboardingStep } from "./OnboardingStep";
import { useOnboarding } from "./OnboardingContext";
/* ------------------------------------------------------------------ */
/* Props */
/* ------------------------------------------------------------------ */
import { OnboardingProviderDispatch } from "./provider-cards/ProviderCard";
interface ProvidersStepProps {
onContinue: () => void;
onBack: () => void;
}
const COMPAT_SLOTS = ["claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5", "claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10"] as const;
/* ------------------------------------------------------------------ */
/* Main component */
/* ------------------------------------------------------------------ */
const FEATURED = listFeaturedOnboardingConnectors();
const FEATURED_IDS = new Set(FEATURED.map((entry) => entry.id));
const ADVANCED = CONNECTOR_CATALOG.filter((entry) => !FEATURED_IDS.has(entry.id));
export function ProvidersStep({ onContinue, onBack }: ProvidersStepProps) {
const {
connectors,
onTestConnector,
onSaveConnector,
onLoginConnector,
onInstallCommand,
hasAtLeastOneReady,
error,
t,
} = useOnboarding();
const [expandedCard, setExpandedCard] = useState<string | null>("claude");
const claudeConnector = connectors.find((c) => c.id === "claude_local");
const codexConnector = connectors.find((c) => c.id === "codex_local");
const geminiConnector = connectors.find((c) => c.id === "gemini_local");
function parseEnvValue(envText: string, key: string): string {
const line = envText.split(/\r?\n/).find((l) => l.startsWith(`${key}=`));
return line ? line.slice(key.length + 1) : "";
}
const [claudeApiKey, setClaudeApiKey] = useState(() =>
parseEnvValue(claudeConnector?.envBindingText ?? "", "ANTHROPIC_API_KEY"),
const { t, error } = useOnboarding();
const [credentialsMap, setCredentialsMap] = useState<Map<string, CredentialSummary>>(
new Map(),
);
const [codexApiKey, setCodexApiKey] = useState(() =>
parseEnvValue(codexConnector?.envBindingText ?? "", "OPENAI_API_KEY"),
const [expandedId, setExpandedId] = useState<string | null>(FEATURED[0]?.id ?? null);
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
const desktop = window.agentCompany;
if (!desktop) return;
let cancelled = false;
const refresh = async () => {
const result = await desktop.listCredentials();
if (cancelled || !result.ok) return;
setCredentialsMap(new Map(result.data.map((row) => [row.connectorId, row])));
};
void refresh();
const unsubscribe = desktop.subscribe((event) => {
if (event.type !== "credentials-changed") return;
void refresh();
});
return () => {
cancelled = true;
unsubscribe();
};
}, []);
const readyCount = useMemo(
() => Array.from(credentialsMap.values()).filter((c) => c.status === "active").length,
[credentialsMap],
);
const canContinue = readyCount > 0;
const compatSlots = COMPAT_SLOTS.map((id) => connectors.find((c) => c.id === id));
const activeCompatSlots = COMPAT_SLOTS.filter((id) => {
const c = connectors.find((conn) => conn.id === id);
return c && c.envBindingText.includes("ANTHROPIC_BASE_URL");
});
const nextAvailableSlot = COMPAT_SLOTS.find((id) => !activeCompatSlots.includes(id)) ?? null;
const visibleCompatSlots = [...activeCompatSlots];
if (nextAvailableSlot && !visibleCompatSlots.includes(nextAvailableSlot)) {
visibleCompatSlots.push(nextAvailableSlot);
}
const claudeStatus = claudeConnector?.status ?? "not_installed";
const codexStatus = codexConnector?.status ?? "not_installed";
const geminiStatus = geminiConnector?.status ?? "not_installed";
const toggle = (card: string) => setExpandedCard((prev) => (prev === card ? null : card));
function handleClaudeSave() {
const lines: string[] = [];
if (claudeApiKey.trim()) lines.push(`ANTHROPIC_API_KEY=${claudeApiKey.trim()}`);
onSaveConnector("claude_local", lines.join("\n"), null);
}
function handleCodexSave() {
const lines: string[] = [];
if (codexApiKey.trim()) lines.push(`OPENAI_API_KEY=${codexApiKey.trim()}`);
onSaveConnector("codex_local", lines.join("\n"), null);
}
const toggle = (id: string) =>
setExpandedId((current) => (current === id ? null : id));
return (
<OnboardingStep
step="providers"
eyebrow={t("onboarding.stepLabelProviders")}
title={t("onboarding.providersTitle")}
subtitle={t("onboarding.providersSubtitle")}
title={t("onboarding.providers.title")}
subtitle={t("onboarding.providers.subtitle")}
>
<div className="space-y-3">
{/* ---- Card 1: Claude Code (Official) ---- */}
<ProviderCard
title={t("onboarding.claudeCodeLabel")}
badge={<StatusBadge status={claudeStatus} t={t} />}
expanded={expandedCard === "claude"}
onToggle={() => toggle("claude")}
>
{claudeStatus === "not_installed" && (
<InstallBlock
macLinuxCmd="curl -fsSL https://claude.ai/install.sh | bash"
windowsCmd="irm https://claude.ai/install.ps1 | iex"
commandKeyUnix="install_claude_unix"
commandKeyWin32="install_claude_win32"
onInstall={onInstallCommand}
t={t}
/>
)}
<button
type="button"
onClick={() => onLoginConnector("claude_local")}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<SignIn size={14} />
{t("onboarding.providerLogin")}
</button>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{t("onboarding.providerApiKey")} <span className="font-normal">(optional)</span>
</label>
<input
type="password"
className={INPUT_CLS}
value={claudeApiKey}
onChange={(e) => setClaudeApiKey(e.target.value)}
placeholder="sk-ant-..."
/>
</div>
<button
type="button"
onClick={handleClaudeSave}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<FloppyDisk size={14} />
{t("onboarding.providerSaveCheck")}
</button>
</ProviderCard>
{/* ---- Card 2: Codex (Official) ---- */}
<ProviderCard
title={t("onboarding.codexLabel")}
badge={<StatusBadge status={codexStatus} t={t} />}
expanded={expandedCard === "codex"}
onToggle={() => toggle("codex")}
>
{codexStatus === "not_installed" && (
<InstallBlock
macLinuxCmd="npm i -g @openai/codex"
windowsCmd={null}
commandKeyUnix="install_codex"
commandKeyWin32={null}
onInstall={onInstallCommand}
t={t}
/>
)}
<button
type="button"
onClick={() => onLoginConnector("codex_local")}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<SignIn size={14} />
{t("onboarding.providerLogin")}
</button>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{t("onboarding.providerApiKey")} (OPENAI_API_KEY)
</label>
<input
type="password"
className={INPUT_CLS}
value={codexApiKey}
onChange={(e) => setCodexApiKey(e.target.value)}
placeholder="sk-..."
/>
</div>
<button
type="button"
onClick={handleCodexSave}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<FloppyDisk size={14} />
{t("onboarding.providerSaveCheck")}
</button>
</ProviderCard>
{/* ---- Card 3: Gemini CLI (Official) ---- */}
<ProviderCard
title={t("onboarding.geminiLabel")}
badge={<StatusBadge status={geminiStatus} t={t} />}
expanded={expandedCard === "gemini"}
onToggle={() => toggle("gemini")}
>
{geminiStatus === "not_installed" && (
<InstallBlock
macLinuxCmd="npm install -g @google/gemini-cli"
windowsCmd={null}
commandKeyUnix="install_gemini"
commandKeyWin32={null}
onInstall={onInstallCommand}
t={t}
/>
)}
<button
type="button"
onClick={() => onLoginConnector("gemini_local")}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<SignIn size={14} />
{t("onboarding.providerLogin")}
</button>
<button
type="button"
onClick={() => onTestConnector("gemini_local")}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<FloppyDisk size={14} />
{t("onboarding.providerCheck")}
</button>
</ProviderCard>
{/* ---- Compatible API providers ---- */}
{visibleCompatSlots.map((slotId) => (
<CompatProviderCard
key={slotId}
slotId={slotId}
connector={compatSlots[COMPAT_SLOTS.indexOf(slotId)] ?? null}
claudeInstalled={claudeStatus !== "not_installed"}
onSaveConnector={onSaveConnector}
onInstallCommand={onInstallCommand}
expanded={expandedCard === slotId}
onToggle={() => toggle(slotId)}
<section aria-label={t("onboarding.providers.featuredHeading")} className="space-y-3">
<div className="flex items-baseline justify-between">
<h3 className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{t("onboarding.providers.featuredHeading")}
</h3>
<span className="text-[11px] text-muted-foreground/70">
{readyCount > 0
? `${readyCount} ${t("onboarding.providers.ready")}`
: t("onboarding.providers.noneConfigured")}
</span>
</div>
{FEATURED.map((entry) => (
<OnboardingProviderDispatch
key={entry.id}
entry={entry}
credential={credentialsMap.get(entry.id) ?? null}
expanded={expandedId === entry.id}
onToggle={() => toggle(entry.id)}
t={t}
/>
))}
</div>
</section>
<section aria-label={t("onboarding.providers.advancedHeading")} className="mt-6">
<button
type="button"
onClick={() => setAdvancedOpen((value) => !value)}
className="flex w-full items-center justify-between rounded-md border border-border bg-card px-4 py-2.5 text-left transition hover:bg-muted/40"
>
<span className="flex items-center gap-2">
{advancedOpen ? (
<CaretDown size={13} className="text-muted-foreground" />
) : (
<CaretRight size={13} className="text-muted-foreground" />
)}
<span className="text-[12px] font-semibold text-foreground">
{t("onboarding.providers.advancedHeading")}
</span>
</span>
<span className="text-[11px] text-muted-foreground/70">
{`${ADVANCED.length} ${t("onboarding.providers.providersLabel")}`}
</span>
</button>
{advancedOpen ? (
<div className="mt-3 space-y-3">
{ADVANCED.map((entry) => (
<OnboardingProviderDispatch
key={entry.id}
entry={entry}
credential={credentialsMap.get(entry.id) ?? null}
expanded={expandedId === entry.id}
onToggle={() => toggle(entry.id)}
t={t}
/>
))}
</div>
) : null}
</section>
{error ? (
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
@@ -254,17 +139,17 @@ export function ProvidersStep({ onContinue, onBack }: ProvidersStepProps) {
{t("onboarding.back")}
</button>
<div className="flex items-center gap-4">
{!hasAtLeastOneReady && (
{!canContinue ? (
<span className="text-[12px] text-muted-foreground">
{t("onboarding.providerAtLeastOne")}
{t("onboarding.providers.atLeastOne")}
</span>
)}
) : null}
<ActionButton
label={t("onboarding.continue")}
onClick={onContinue}
icon={ArrowRight}
tone="accent"
disabled={!hasAtLeastOneReady}
disabled={!canContinue}
/>
</div>
</div>
@@ -1,352 +0,0 @@
import { useState } from "react";
import {
ArrowSquareOut,
CaretDown,
CaretRight,
FloppyDisk,
Terminal,
} from "../ui/icons";
import type { ProfileSnapshot } from "@shared/types";
import type { TerminalCommandKey } from "@shared/contracts";
/* ------------------------------------------------------------------ */
/* Compatible-API provider presets */
/* ------------------------------------------------------------------ */
export const COMPATIBLE_PROVIDERS = [
{ id: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
{ id: "minimax_cn", label: "MiniMax (CN)", baseUrl: "https://api.minimaxi.com/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
{ id: "glm", label: "GLM (ZhiPu)", baseUrl: "https://api.z.ai/api/anthropic", docUrl: "https://docs.z.ai/devpack/tool/claude" },
{ id: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api", docUrl: "https://openrouter.ai/docs/guides/coding-agents/claude-code-integration" },
{ id: "tencent", label: "Tencent Coding Plan", baseUrl: "https://api.lkeap.cloud.tencent.com/coding/anthropic", docUrl: "https://cloud.tencent.com/document/product/1772/128951" },
{ id: "poe", label: "POE", baseUrl: "https://api.poe.com", docUrl: "https://creator.poe.com/docs/external-applications/claude-code" },
{ id: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/anthropic", docUrl: "https://api-docs.deepseek.com/guides/anthropic_api" },
{ id: "alibaba", label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan" },
{ id: "alibaba_cn", label: "Alibaba Coding Plan (CN)", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://help.aliyun.com/zh/model-studio/claude-code-coding-plan" },
{ id: "bailing", label: "BaiLing", baseUrl: "https://api.tbox.cn/api/anthropic", docUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/best_practices_claudecode" },
{ id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", docUrl: "https://docs.nano-gpt.com/integrations/claude-code#manual-configuration-all-platforms" },
{ id: "zhipu_cn", label: "ZhiPu (CN)", baseUrl: "https://open.bigmodel.cn/api/anthropic", docUrl: "https://docs.bigmodel.cn/cn/coding-plan/tool/claude" },
{ id: "ollama", label: "Ollama (Local)", baseUrl: "http://localhost:11434", docUrl: "https://docs.ollama.com/integrations/claude-code" },
{ id: "custom", label: "Custom", baseUrl: "", docUrl: "" },
] as const;
/* ------------------------------------------------------------------ */
/* Shared input class */
/* ------------------------------------------------------------------ */
export const INPUT_CLS =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type ConnectorStatus = ProfileSnapshot["connectors"][number]["status"];
export interface ProvidersStepSaveConnector {
(connectorId: string, envBindingText: string, model: string | null, notes?: string): void;
}
/* ------------------------------------------------------------------ */
/* Status badge */
/* ------------------------------------------------------------------ */
export function StatusBadge({ status, t }: { status: ConnectorStatus; t: (key: string) => string }) {
if (status === "ready") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-muted px-2 py-0.5 text-[11px] font-medium text-success">
<span className="h-1.5 w-1.5 rounded-full bg-success" />
{t("onboarding.providerReady")}
</span>
);
}
if (status === "not_installed") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground" />
{t("onboarding.providerNotInstalled")}
</span>
);
}
if (status === "auth_required") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-warning-muted px-2 py-0.5 text-[11px] font-medium text-warning">
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
{t("onboarding.providerAuthRequired")}
</span>
);
}
if (status === "detected" || status === "checking" as string) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-primary" />
{t("onboarding.providerChecking")}
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-caution-muted px-2 py-0.5 text-[11px] font-medium text-caution">
<span className="h-1.5 w-1.5 rounded-full bg-caution" />
{status.replaceAll("_", " ")}
</span>
);
}
/* ------------------------------------------------------------------ */
/* Provider card wrapper */
/* ------------------------------------------------------------------ */
export function ProviderCard({
title,
badge,
expanded,
onToggle,
children,
}: {
title: string;
badge: React.ReactNode;
expanded: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<button
type="button"
onClick={onToggle}
className="flex w-full cursor-pointer items-center justify-between px-4 py-3 transition hover:bg-muted/50"
>
<div className="flex items-center gap-2.5">
{expanded ? <CaretDown size={14} className="text-muted-foreground" /> : <CaretRight size={14} className="text-muted-foreground" />}
<span className="text-[13px] font-semibold text-foreground">{title}</span>
</div>
{badge}
</button>
{expanded && <div className="space-y-3 px-4 pb-4">{children}</div>}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Install snippet */
/* ------------------------------------------------------------------ */
export function InstallBlock({
macLinuxCmd,
windowsCmd,
commandKeyUnix,
commandKeyWin32,
onInstall,
t,
}: {
macLinuxCmd: string;
windowsCmd: string | null;
commandKeyUnix: TerminalCommandKey;
commandKeyWin32: TerminalCommandKey | null;
onInstall: (commandKey: TerminalCommandKey) => void;
t: (key: string) => string;
}) {
const platform = window.agentCompany?.platform ?? "linux";
const isWindows = platform === "win32";
const commandKey = isWindows && commandKeyWin32 ? commandKeyWin32 : commandKeyUnix;
return (
<div className="space-y-2">
<div className="text-[12px] font-medium text-muted-foreground">{t("onboarding.providerInstall")}</div>
{!isWindows && (
<div className="rounded-lg border border-border bg-muted px-3 py-2 font-mono text-[12px] text-foreground">
{macLinuxCmd}
</div>
)}
{isWindows && windowsCmd && (
<div className="rounded-lg border border-border bg-muted px-3 py-2 font-mono text-[12px] text-foreground">
{windowsCmd}
</div>
)}
<button
type="button"
onClick={() => onInstall(commandKey)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<Terminal size={14} />
{t("onboarding.providerInstall")}
</button>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Compatible provider card (one per slot) */
/* ------------------------------------------------------------------ */
export function CompatProviderCard({
slotId,
connector,
claudeInstalled,
onSaveConnector,
onInstallCommand,
expanded,
onToggle,
t,
}: {
slotId: string;
connector: ProfileSnapshot["connectors"][number] | null;
claudeInstalled: boolean;
onSaveConnector: ProvidersStepSaveConnector;
onInstallCommand: (commandKey: TerminalCommandKey) => void;
expanded: boolean;
onToggle: () => void;
t: (key: string) => string;
}) {
function parseEnv(text: string, key: string): string {
const line = text.split(/\r?\n/).find((l) => l.startsWith(`${key}=`));
return line ? line.slice(key.length + 1) : "";
}
const [selectedProvider, setSelectedProvider] = useState<string>(() => {
const saved = connector?.notes ?? "";
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
return match?.id ?? COMPATIBLE_PROVIDERS[0].id;
});
const [apiKey, setApiKey] = useState(() => parseEnv(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"));
const [baseUrl, setBaseUrl] = useState<string>(() =>
parseEnv(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL") || COMPATIBLE_PROVIDERS[0].baseUrl,
);
const [models, setModels] = useState<string[]>(() => {
const raw = connector?.model ?? "";
return raw.split(",").map((m) => m.trim()).filter(Boolean);
});
const [newModel, setNewModel] = useState("");
const status = connector?.status ?? "not_installed";
const providerLabel = COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ?? t("onboarding.claudeCompatibleLabel");
const displayTitle = connector?.notes || providerLabel;
function handleProviderChange(providerId: string) {
setSelectedProvider(providerId);
const provider = COMPATIBLE_PROVIDERS.find((p) => p.id === providerId);
if (provider) setBaseUrl(provider.baseUrl);
}
function handleSave() {
const lines: string[] = [];
if (apiKey.trim()) lines.push(`ANTHROPIC_API_KEY=${apiKey.trim()}`);
if (baseUrl.trim()) lines.push(`ANTHROPIC_BASE_URL=${baseUrl.trim()}`);
const label = COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ?? "";
const allModels = [...models];
const pending = newModel.trim();
if (pending && !allModels.includes(pending)) {
allModels.push(pending);
setModels(allModels);
setNewModel("");
}
const modelsCsv = allModels.join(",") || null;
onSaveConnector(slotId, lines.join("\n"), modelsCsv, label);
}
function addModel() {
const trimmed = newModel.trim();
if (!trimmed) return;
setModels((prev) => {
if (prev.includes(trimmed)) return prev;
return [...prev, trimmed];
});
setNewModel("");
}
const selectedProviderData = COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider);
return (
<ProviderCard
title={displayTitle}
badge={<StatusBadge status={status} t={t} />}
expanded={expanded}
onToggle={onToggle}
>
{!claudeInstalled && (
<InstallBlock
macLinuxCmd="curl -fsSL https://claude.ai/install.sh | bash"
windowsCmd="irm https://claude.ai/install.ps1 | iex"
commandKeyUnix="install_claude_unix"
commandKeyWin32="install_claude_win32"
onInstall={onInstallCommand}
t={t}
/>
)}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{t("onboarding.providerSelectProvider")}
</label>
<select
className={INPUT_CLS}
value={selectedProvider}
onChange={(e) => handleProviderChange(e.target.value)}
>
{COMPATIBLE_PROVIDERS.map((p) => (
<option key={p.id} value={p.id} className="bg-card text-foreground">{p.label}</option>
))}
</select>
</div>
{selectedProviderData?.docUrl ? (
<button
type="button"
onClick={() => window.open(selectedProviderData.docUrl, "_blank")}
className="inline-flex items-center gap-1 text-[12px] text-primary transition hover:underline"
>
<ArrowSquareOut size={12} />
{t("onboarding.providerViewDocs")}
</button>
) : null}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("onboarding.providerApiKey")}</label>
<input type="password" className={INPUT_CLS} value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-..." />
</div>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("onboarding.providerBaseUrl")}</label>
<input className={INPUT_CLS} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" />
</div>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("onboarding.providerModel")}</label>
{models.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{models.map((m) => (
<span key={m} className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-[12px] text-foreground">
{m}
<button type="button" onClick={() => setModels(models.filter((x) => x !== m))} className="ml-0.5 text-muted-foreground hover:text-destructive">×</button>
</span>
))}
</div>
)}
<div className="flex gap-2">
<input
className={INPUT_CLS}
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
placeholder="model-name"
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addModel(); } }}
/>
<button
type="button"
onClick={addModel}
disabled={!newModel.trim()}
className="shrink-0 rounded-lg border border-border bg-card px-3 py-2 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
>+</button>
</div>
</div>
<button
type="button"
onClick={handleSave}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<FloppyDisk size={14} />
{t("onboarding.providerSaveCheck")}
</button>
</ProviderCard>
);
}
@@ -0,0 +1,35 @@
import type { ConnectorCatalogEntry } from "@shared/connector-catalog";
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
import type { TranslationFn } from "../../../i18n";
import { OAuthProviderCard } from "./oauth-card";
import { ApiKeyProviderCard } from "./api-key-card";
import { CloudProviderCard } from "./cloud-card";
export interface ProviderCardProps {
entry: ConnectorCatalogEntry;
credential: CredentialSummary | null;
expanded: boolean;
onToggle: () => void;
onOpenSettings?: () => void;
t: TranslationFn;
}
/**
* Dispatch based on the catalog auth descriptor. Five auth kinds live behind
* three dedicated cards — cloud variants share a card because their onboarding
* affordance is identical (deep link to Settings). A universal form generator
* is intentionally avoided so each auth flow stays legible end-to-end.
*/
export function OnboardingProviderDispatch(props: ProviderCardProps) {
const { entry } = props;
switch (entry.auth.type) {
case "oauth":
return <OAuthProviderCard {...props} />;
case "api_key":
return <ApiKeyProviderCard {...props} />;
case "aws":
case "azure":
case "gcp_adc":
return <CloudProviderCard {...props} />;
}
}
@@ -0,0 +1,137 @@
import { useState } from "react";
import { FloppyDisk } from "../../ui/icons";
import type { ConnectorCatalogEntry } from "@shared/connector-catalog";
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
import type { TranslationFn } from "../../../i18n";
import {
INPUT_CLS,
InlineFeedback,
OnboardingProviderCard,
OnboardingStatusBadge,
type OnboardingCardStatus,
} from "./shared";
interface ApiKeyCardProps {
entry: ConnectorCatalogEntry;
credential: CredentialSummary | null;
expanded: boolean;
onToggle: () => void;
t: TranslationFn;
}
export function ApiKeyProviderCard({
entry,
credential,
expanded,
onToggle,
t,
}: ApiKeyCardProps) {
if (entry.auth.type !== "api_key") {
throw new Error(`ApiKeyProviderCard received non-api_key connector: ${entry.id}`);
}
const envKey = entry.auth.envKey;
const [value, setValue] = useState("");
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
const status: OnboardingCardStatus = busy
? "checking"
: credential?.status ?? "not_configured";
const connected = credential?.status === "active";
const handleSave = async () => {
if (!window.agentCompany) return;
const trimmed = value.trim();
if (trimmed.length === 0) {
setFeedback(t("onboarding.providers.apiKeyRequired"));
return;
}
setBusy(true);
setFeedback(null);
const result = await window.agentCompany.saveCredential({
connectorId: entry.id,
payload: {
kind: "api_key",
value: { source: "literal", value: trimmed },
},
});
setBusy(false);
if (!result.ok) {
setFeedback(result.error.message);
return;
}
setValue("");
setFeedback(t("onboarding.providers.saved"));
};
const handleRemove = async () => {
if (!window.agentCompany) return;
setBusy(true);
setFeedback(null);
const result = await window.agentCompany.removeCredential({ connectorId: entry.id });
setBusy(false);
if (!result.ok) setFeedback(result.error.message);
};
return (
<OnboardingProviderCard
label={entry.label}
description={entry.description}
badge={<OnboardingStatusBadge status={status} t={t} />}
expanded={expanded}
onToggle={onToggle}
>
{entry.notes ? (
<div className="text-[12px] leading-relaxed text-muted-foreground">{entry.notes}</div>
) : null}
<form
onSubmit={(event) => {
event.preventDefault();
void handleSave();
}}
className="space-y-3"
>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{t("onboarding.providers.apiKeyLabel")}{" "}
<span className="font-mono text-[11px] text-muted-foreground/70">({envKey})</span>
</label>
<input
type="password"
className={INPUT_CLS}
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={connected ? t("onboarding.providers.apiKeyReplacePlaceholder") : ""}
autoComplete="off"
spellCheck={false}
disabled={busy}
/>
</div>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={busy || value.trim().length === 0}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
>
<FloppyDisk size={14} />
{busy ? t("onboarding.providers.saving") : t("onboarding.providers.save")}
</button>
{connected ? (
<button
type="button"
onClick={() => void handleRemove()}
disabled={busy}
className="text-[12px] text-muted-foreground transition hover:text-destructive disabled:opacity-40"
>
{t("onboarding.providers.disconnect")}
</button>
) : null}
</div>
</form>
{credential?.displayHint ? (
<div className="text-[11px] text-muted-foreground">{credential.displayHint}</div>
) : null}
<InlineFeedback message={feedback} />
</OnboardingProviderCard>
);
}
@@ -0,0 +1,66 @@
import { ArrowSquareOut } from "../../ui/icons";
import type { ConnectorCatalogEntry } from "@shared/connector-catalog";
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
import type { TranslationFn } from "../../../i18n";
import {
InlineFeedback,
OnboardingProviderCard,
OnboardingStatusBadge,
type OnboardingCardStatus,
} from "./shared";
interface CloudCardProps {
entry: ConnectorCatalogEntry;
credential: CredentialSummary | null;
expanded: boolean;
onToggle: () => void;
onOpenSettings?: () => void;
t: TranslationFn;
}
/**
* Cloud connectors (AWS / Azure / GCP ADC) need multi-field forms. During
* onboarding we show a short primer and route the user to Settings where the
* dedicated forms live. Status reflects an existing credential if one was
* already set up outside onboarding.
*/
export function CloudProviderCard({
entry,
credential,
expanded,
onToggle,
onOpenSettings,
t,
}: CloudCardProps) {
const status: OnboardingCardStatus = credential?.status ?? "not_configured";
return (
<OnboardingProviderCard
label={entry.label}
description={entry.description}
badge={<OnboardingStatusBadge status={status} t={t} />}
expanded={expanded}
onToggle={onToggle}
>
{entry.notes ? (
<div className="text-[12px] leading-relaxed text-muted-foreground">{entry.notes}</div>
) : null}
<div className="rounded-md border border-dashed border-border bg-muted/30 px-3 py-2.5 text-[12px] leading-relaxed text-muted-foreground">
{t("onboarding.providers.cloudPrimer")}
</div>
<button
type="button"
onClick={onOpenSettings}
disabled={!onOpenSettings}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
>
<ArrowSquareOut size={14} />
{t("onboarding.providers.configureInSettings")}
</button>
{credential?.displayHint ? (
<div className="text-[11px] text-muted-foreground">{credential.displayHint}</div>
) : null}
<InlineFeedback message={null} />
</OnboardingProviderCard>
);
}
@@ -0,0 +1,96 @@
import { useState } from "react";
import { SignIn } from "../../ui/icons";
import type { ConnectorCatalogEntry } from "@shared/connector-catalog";
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
import type { TranslationFn } from "../../../i18n";
import {
InlineFeedback,
OnboardingProviderCard,
OnboardingStatusBadge,
type OnboardingCardStatus,
} from "./shared";
interface OAuthCardProps {
entry: ConnectorCatalogEntry;
credential: CredentialSummary | null;
expanded: boolean;
onToggle: () => void;
t: TranslationFn;
}
export function OAuthProviderCard({
entry,
credential,
expanded,
onToggle,
t,
}: OAuthCardProps) {
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
const status: OnboardingCardStatus = busy
? "checking"
: credential?.status ?? "not_configured";
const connected = credential?.status === "active";
const handleLogin = async () => {
if (!window.agentCompany) return;
setBusy(true);
setFeedback(null);
const result = await window.agentCompany.startOAuth({ connectorId: entry.id });
setBusy(false);
if (!result.ok) setFeedback(result.error.message);
};
const handleRemove = async () => {
if (!window.agentCompany) return;
setBusy(true);
setFeedback(null);
const result = await window.agentCompany.removeCredential({ connectorId: entry.id });
setBusy(false);
if (!result.ok) setFeedback(result.error.message);
};
return (
<OnboardingProviderCard
label={entry.label}
description={entry.description}
badge={<OnboardingStatusBadge status={status} t={t} />}
expanded={expanded}
onToggle={onToggle}
>
{entry.notes ? (
<div className="text-[12px] leading-relaxed text-muted-foreground">{entry.notes}</div>
) : null}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleLogin()}
disabled={busy}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
>
<SignIn size={14} />
{busy
? t("onboarding.providers.signingIn")
: connected
? t("onboarding.providers.reconnect")
: t("onboarding.providers.signIn")}
</button>
{connected ? (
<button
type="button"
onClick={() => void handleRemove()}
disabled={busy}
className="text-[12px] text-muted-foreground transition hover:text-destructive disabled:opacity-40"
>
{t("onboarding.providers.disconnect")}
</button>
) : null}
</div>
{credential?.displayHint ? (
<div className="text-[11px] text-muted-foreground">{credential.displayHint}</div>
) : null}
<InlineFeedback message={feedback} />
</OnboardingProviderCard>
);
}
@@ -0,0 +1,103 @@
import { CaretDown, CaretRight } from "../../ui/icons";
import type { ReactNode } from "react";
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
import type { TranslationFn } from "../../../i18n";
export const INPUT_CLS =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground";
export type OnboardingCardStatus = CredentialSummary["status"] | "checking" | "not_configured";
export function OnboardingProviderCard({
label,
description,
badge,
expanded,
onToggle,
children,
}: {
label: string;
description: string;
badge: ReactNode;
expanded: boolean;
onToggle: () => void;
children: ReactNode;
}) {
return (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<button
type="button"
onClick={onToggle}
className="flex w-full cursor-pointer items-start justify-between gap-3 px-4 py-3 text-left transition hover:bg-muted/40"
>
<div className="flex min-w-0 items-start gap-2.5">
{expanded ? (
<CaretDown size={14} className="mt-1 flex-shrink-0 text-muted-foreground" />
) : (
<CaretRight size={14} className="mt-1 flex-shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold text-foreground">{label}</div>
<div className="mt-0.5 text-[12px] leading-snug text-muted-foreground line-clamp-2">
{description}
</div>
</div>
</div>
<div className="flex-shrink-0 pt-0.5">{badge}</div>
</button>
{expanded ? <div className="space-y-3 px-4 pb-4">{children}</div> : null}
</div>
);
}
export function OnboardingStatusBadge({
status,
t,
}: {
status: OnboardingCardStatus;
t: TranslationFn;
}) {
if (status === "active") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-muted px-2 py-0.5 text-[11px] font-medium text-success">
<span className="h-1.5 w-1.5 rounded-full bg-success" />
{t("onboarding.providers.statusReady")}
</span>
);
}
if (status === "expired") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-warning-muted px-2 py-0.5 text-[11px] font-medium text-warning">
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
{t("onboarding.providers.statusExpired")}
</span>
);
}
if (status === "error" || status === "revoked") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-caution-muted px-2 py-0.5 text-[11px] font-medium text-caution">
<span className="h-1.5 w-1.5 rounded-full bg-caution" />
{t("onboarding.providers.statusError")}
</span>
);
}
if (status === "checking") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-primary" />
{t("onboarding.providers.statusChecking")}
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground" />
{t("onboarding.providers.statusNotConfigured")}
</span>
);
}
export function InlineFeedback({ message }: { message: string | null }) {
if (!message) return null;
return <div className="pt-1 text-[11px] leading-relaxed text-muted-foreground">{message}</div>;
}