From 7fdd3c16bf47ee9bcce346e9fe8f38c79b68d754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 17 Apr 2026 02:16:35 -0400 Subject: [PATCH] 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. --- .../company-onboarding/ProvidersStep.tsx | 325 ++++++---------- .../ProvidersStepShared.tsx | 352 ------------------ .../provider-cards/ProviderCard.tsx | 35 ++ .../provider-cards/api-key-card.tsx | 137 +++++++ .../provider-cards/cloud-card.tsx | 66 ++++ .../provider-cards/oauth-card.tsx | 96 +++++ .../provider-cards/shared.tsx | 103 +++++ src/renderer/components/connectors.tsx | 284 ++++---------- src/renderer/components/execution.tsx | 1 - .../execution/CompatProviderCard.tsx | 328 ---------------- .../components/execution/ModelManagement.tsx | 94 ----- .../execution/ModelManagementShared.tsx | 8 - .../execution/NativeProviderCard.tsx | 168 --------- .../organization/agent-form/useAgentForm.ts | 59 ++- src/renderer/i18n/en.ts | 25 ++ src/renderer/i18n/zh.ts | 25 ++ .../views/sections/OperationsSections.tsx | 8 +- 17 files changed, 692 insertions(+), 1422 deletions(-) delete mode 100644 src/renderer/components/company-onboarding/ProvidersStepShared.tsx create mode 100644 src/renderer/components/company-onboarding/provider-cards/ProviderCard.tsx create mode 100644 src/renderer/components/company-onboarding/provider-cards/api-key-card.tsx create mode 100644 src/renderer/components/company-onboarding/provider-cards/cloud-card.tsx create mode 100644 src/renderer/components/company-onboarding/provider-cards/oauth-card.tsx create mode 100644 src/renderer/components/company-onboarding/provider-cards/shared.tsx delete mode 100644 src/renderer/components/execution/CompatProviderCard.tsx delete mode 100644 src/renderer/components/execution/ModelManagement.tsx delete mode 100644 src/renderer/components/execution/NativeProviderCard.tsx diff --git a/src/renderer/components/company-onboarding/ProvidersStep.tsx b/src/renderer/components/company-onboarding/ProvidersStep.tsx index 9c4f6771..45df190b 100644 --- a/src/renderer/components/company-onboarding/ProvidersStep.tsx +++ b/src/renderer/components/company-onboarding/ProvidersStep.tsx @@ -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("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>( + new Map(), ); - const [codexApiKey, setCodexApiKey] = useState(() => - parseEnvValue(codexConnector?.envBindingText ?? "", "OPENAI_API_KEY"), + const [expandedId, setExpandedId] = useState(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 ( - -
- {/* ---- Card 1: Claude Code (Official) ---- */} - } - expanded={expandedCard === "claude"} - onToggle={() => toggle("claude")} - > - {claudeStatus === "not_installed" && ( - - )} - -
- - setClaudeApiKey(e.target.value)} - placeholder="sk-ant-..." - /> -
- -
- - {/* ---- Card 2: Codex (Official) ---- */} - } - expanded={expandedCard === "codex"} - onToggle={() => toggle("codex")} - > - {codexStatus === "not_installed" && ( - - )} - -
- - setCodexApiKey(e.target.value)} - placeholder="sk-..." - /> -
- -
- - {/* ---- Card 3: Gemini CLI (Official) ---- */} - } - expanded={expandedCard === "gemini"} - onToggle={() => toggle("gemini")} - > - {geminiStatus === "not_installed" && ( - - )} - - - - - {/* ---- Compatible API providers ---- */} - {visibleCompatSlots.map((slotId) => ( - toggle(slotId)} +
+
+

+ {t("onboarding.providers.featuredHeading")} +

+ + {readyCount > 0 + ? `${readyCount} ${t("onboarding.providers.ready")}` + : t("onboarding.providers.noneConfigured")} + +
+ {FEATURED.map((entry) => ( + toggle(entry.id)} t={t} /> ))} -
+ + +
+ + {advancedOpen ? ( +
+ {ADVANCED.map((entry) => ( + toggle(entry.id)} + t={t} + /> + ))} +
+ ) : null} +
{error ? (
@@ -254,17 +139,17 @@ export function ProvidersStep({ onContinue, onBack }: ProvidersStepProps) { {t("onboarding.back")}
- {!hasAtLeastOneReady && ( + {!canContinue ? ( - {t("onboarding.providerAtLeastOne")} + {t("onboarding.providers.atLeastOne")} - )} + ) : null}
diff --git a/src/renderer/components/company-onboarding/ProvidersStepShared.tsx b/src/renderer/components/company-onboarding/ProvidersStepShared.tsx deleted file mode 100644 index 629fe8c4..00000000 --- a/src/renderer/components/company-onboarding/ProvidersStepShared.tsx +++ /dev/null @@ -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 ( - - - {t("onboarding.providerReady")} - - ); - } - if (status === "not_installed") { - return ( - - - {t("onboarding.providerNotInstalled")} - - ); - } - if (status === "auth_required") { - return ( - - - {t("onboarding.providerAuthRequired")} - - ); - } - if (status === "detected" || status === "checking" as string) { - return ( - - - {t("onboarding.providerChecking")} - - ); - } - return ( - - - {status.replaceAll("_", " ")} - - ); -} - -/* ------------------------------------------------------------------ */ -/* Provider card wrapper */ -/* ------------------------------------------------------------------ */ - -export function ProviderCard({ - title, - badge, - expanded, - onToggle, - children, -}: { - title: string; - badge: React.ReactNode; - expanded: boolean; - onToggle: () => void; - children: React.ReactNode; -}) { - return ( -
- - {expanded &&
{children}
} -
- ); -} - -/* ------------------------------------------------------------------ */ -/* 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 ( -
-
{t("onboarding.providerInstall")}
- {!isWindows && ( -
- {macLinuxCmd} -
- )} - {isWindows && windowsCmd && ( -
- {windowsCmd} -
- )} - -
- ); -} - -/* ------------------------------------------------------------------ */ -/* 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(() => { - 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(() => - parseEnv(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL") || COMPATIBLE_PROVIDERS[0].baseUrl, - ); - const [models, setModels] = useState(() => { - 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 ( - } - expanded={expanded} - onToggle={onToggle} - > - {!claudeInstalled && ( - - )} - -
- - -
- - {selectedProviderData?.docUrl ? ( - - ) : null} - -
- - setApiKey(e.target.value)} placeholder="sk-..." /> -
- -
- - setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" /> -
- -
- - {models.length > 0 && ( -
- {models.map((m) => ( - - {m} - - - ))} -
- )} -
- setNewModel(e.target.value)} - placeholder="model-name" - onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addModel(); } }} - /> - -
-
- - -
- ); -} diff --git a/src/renderer/components/company-onboarding/provider-cards/ProviderCard.tsx b/src/renderer/components/company-onboarding/provider-cards/ProviderCard.tsx new file mode 100644 index 00000000..ad49075f --- /dev/null +++ b/src/renderer/components/company-onboarding/provider-cards/ProviderCard.tsx @@ -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 ; + case "api_key": + return ; + case "aws": + case "azure": + case "gcp_adc": + return ; + } +} diff --git a/src/renderer/components/company-onboarding/provider-cards/api-key-card.tsx b/src/renderer/components/company-onboarding/provider-cards/api-key-card.tsx new file mode 100644 index 00000000..b4cfaf77 --- /dev/null +++ b/src/renderer/components/company-onboarding/provider-cards/api-key-card.tsx @@ -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(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 ( + } + expanded={expanded} + onToggle={onToggle} + > + {entry.notes ? ( +
{entry.notes}
+ ) : null} +
{ + event.preventDefault(); + void handleSave(); + }} + className="space-y-3" + > +
+ + setValue(event.target.value)} + placeholder={connected ? t("onboarding.providers.apiKeyReplacePlaceholder") : ""} + autoComplete="off" + spellCheck={false} + disabled={busy} + /> +
+
+ + {connected ? ( + + ) : null} +
+
+ {credential?.displayHint ? ( +
{credential.displayHint}
+ ) : null} + +
+ ); +} diff --git a/src/renderer/components/company-onboarding/provider-cards/cloud-card.tsx b/src/renderer/components/company-onboarding/provider-cards/cloud-card.tsx new file mode 100644 index 00000000..1bb6a632 --- /dev/null +++ b/src/renderer/components/company-onboarding/provider-cards/cloud-card.tsx @@ -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 ( + } + expanded={expanded} + onToggle={onToggle} + > + {entry.notes ? ( +
{entry.notes}
+ ) : null} +
+ {t("onboarding.providers.cloudPrimer")} +
+ + {credential?.displayHint ? ( +
{credential.displayHint}
+ ) : null} + +
+ ); +} diff --git a/src/renderer/components/company-onboarding/provider-cards/oauth-card.tsx b/src/renderer/components/company-onboarding/provider-cards/oauth-card.tsx new file mode 100644 index 00000000..b0d6464d --- /dev/null +++ b/src/renderer/components/company-onboarding/provider-cards/oauth-card.tsx @@ -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(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 ( + } + expanded={expanded} + onToggle={onToggle} + > + {entry.notes ? ( +
{entry.notes}
+ ) : null} +
+ + {connected ? ( + + ) : null} +
+ {credential?.displayHint ? ( +
{credential.displayHint}
+ ) : null} + +
+ ); +} diff --git a/src/renderer/components/company-onboarding/provider-cards/shared.tsx b/src/renderer/components/company-onboarding/provider-cards/shared.tsx new file mode 100644 index 00000000..d9dcc32d --- /dev/null +++ b/src/renderer/components/company-onboarding/provider-cards/shared.tsx @@ -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 ( +
+ + {expanded ?
{children}
: null} +
+ ); +} + +export function OnboardingStatusBadge({ + status, + t, +}: { + status: OnboardingCardStatus; + t: TranslationFn; +}) { + if (status === "active") { + return ( + + + {t("onboarding.providers.statusReady")} + + ); + } + if (status === "expired") { + return ( + + + {t("onboarding.providers.statusExpired")} + + ); + } + if (status === "error" || status === "revoked") { + return ( + + + {t("onboarding.providers.statusError")} + + ); + } + if (status === "checking") { + return ( + + + {t("onboarding.providers.statusChecking")} + + ); + } + return ( + + + {t("onboarding.providers.statusNotConfigured")} + + ); +} + +export function InlineFeedback({ message }: { message: string | null }) { + if (!message) return null; + return
{message}
; +} diff --git a/src/renderer/components/connectors.tsx b/src/renderer/components/connectors.tsx index 380deecb..675fd96a 100644 --- a/src/renderer/components/connectors.tsx +++ b/src/renderer/components/connectors.tsx @@ -1,251 +1,103 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { CheckCircle, SignIn } from "./ui/icons"; +import { useEffect, useState } from "react"; import type { ConnectorRecord } from "@shared/types"; -import { unwrap } from "../lib/desktop"; -import { ActionButton, InlineNotice, Input, Select, StatusPill, TextArea } from "./ui"; -import { useT } from "../i18n"; +import { findConnectorCatalogEntry } from "@shared/connector-catalog"; +import type { CredentialSummary } from "@shared/contracts/schemas-credentials"; +import { InlineNotice, StatusPill } from "./ui"; -function getAuthActionCopy(connector: ConnectorRecord) { - switch (connector.id) { - case "codex_local": - return { - title: "Codex login required", - message: "Codex is installed, but the CLI needs an authenticated session before AgentCompany can dispatch runs.", - buttonLabel: "Open Terminal To Sign In", - }; - case "claude_local": - return { - title: "Claude Code login required", - message: "Claude Code is installed, but the local CLI is not authenticated for this desktop runtime.", - buttonLabel: "Open Terminal To Sign In", - }; - case "gemini_local": - return { - title: "Gemini authentication required", - message: "Gemini CLI is available, but the runtime still needs an authenticated session. AgentCompany will open Gemini in a terminal and you can complete its interactive auth flow there.", - buttonLabel: "Open Terminal", - }; - } -} +const AUTH_KIND_LABEL: Record = { + oauth: "OAuth", + api_key: "API key", + aws: "AWS", + azure: "Azure", + gcp_adc: "Google Vertex (ADC)", +}; -export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRecord; onSaved: () => Promise }) { - const t = useT(); - const [model, setModel] = useState(connector.model ?? ""); - const [envBindingText, setEnvBindingText] = useState(connector.envBindingText); - const [notes, setNotes] = useState(connector.notes); - const [submitError, setSubmitError] = useState(null); - const [saving, setSaving] = useState(false); - const [openingAuthTerminal, setOpeningAuthTerminal] = useState(false); - const [autoChecking, setAutoChecking] = useState(false); - const [modelOptions, setModelOptions] = useState>([]); - const [loadingModelOptions, setLoadingModelOptions] = useState(false); - const [modelOptionsError, setModelOptionsError] = useState(null); - const authPollTimeoutRef = useRef | null>(null); - const authActionCopy = getAuthActionCopy(connector); - const needsLogin = connector.authState === "required" || connector.status === "auth_required"; - const shouldLoadModelOptions = connector.capabilityMatrix.modelDiscovery; - - useEffect(() => { - setModel(connector.model ?? ""); - setEnvBindingText(connector.envBindingText); - setNotes(connector.notes); - setSubmitError(null); - }, [connector]); - - useEffect(() => () => { - if (authPollTimeoutRef.current) { - clearTimeout(authPollTimeoutRef.current); - authPollTimeoutRef.current = null; - } - }, []); +/** + * Legacy connector detail view. Real configuration happens in + * Settings → Providers. Here we only surface catalog metadata and the current + * credential status pulled from the pi-mono credential store. + */ +export function ConnectorEditor({ + connector, +}: { + connector: ConnectorRecord; + onSaved: () => Promise; +}) { + const [credential, setCredential] = useState(null); + const [error, setError] = useState(null); + const catalog = findConnectorCatalogEntry(connector.id); useEffect(() => { + const desktop = window.agentCompany; + if (!desktop) return; let cancelled = false; - if (!shouldLoadModelOptions) { - setModelOptions([]); - setModelOptionsError(null); - setLoadingModelOptions(false); - return; - } - setLoadingModelOptions(true); - setModelOptionsError(null); - void window.agentCompany.listConnectorModels(connector.id).then((result) => { + const refresh = async () => { + const result = await desktop.listCredentials(); if (cancelled) return; if (!result.ok) { - setModelOptions([]); - setModelOptionsError(result.error.message); - setLoadingModelOptions(false); + setError(result.error.message); return; } - setModelOptions(result.data); - setLoadingModelOptions(false); + setCredential(result.data.find((row) => row.connectorId === connector.id) ?? null); + }; + void refresh(); + const unsubscribe = desktop.subscribe((event) => { + if (event.type !== "credentials-changed") return; + void refresh(); }); return () => { cancelled = true; + unsubscribe(); }; - }, [connector.id, shouldLoadModelOptions]); - - const modelSelectOptions = useMemo(() => { - const options = [{ value: "", label: "Use connector default" }]; - const hasCurrentModel = modelOptions.some((option) => option.id === model); - if (model && !hasCurrentModel) { - options.push({ value: model, label: `${model} (saved)` }); - } - options.push(...modelOptions.map((option) => ({ value: option.id, label: option.label || option.id }))); - return options; - }, [model, modelOptions]); - - const runHealthCheck = useCallback(async (quiet = false) => { - setAutoChecking(true); - try { - unwrap(await window.agentCompany.testConnector(connector.id)); - await onSaved(); - if (!quiet) { - setSubmitError(null); - } - return true; - } catch (error) { - if (!quiet) { - setSubmitError(error instanceof Error ? error.message : "Automatic connector health check failed."); - } - return false; - } finally { - setAutoChecking(false); - } - }, [connector.id, onSaved]); - - const scheduleAuthPolling = useCallback((remainingAttempts: number) => { - if (authPollTimeoutRef.current) { - clearTimeout(authPollTimeoutRef.current); - authPollTimeoutRef.current = null; - } - if (remainingAttempts <= 0) { - return; - } - authPollTimeoutRef.current = setTimeout(() => { - void (async () => { - const ok = await runHealthCheck(true); - if (!ok) { - scheduleAuthPolling(remainingAttempts - 1); - } - })(); - }, 5000); - }, [runHealthCheck]); + }, [connector.id]); return (
- {submitError ? : null} - {needsLogin && authActionCopy ? ( -
-
{authActionCopy.title}
-
{authActionCopy.message}
-
- void (async () => { - setOpeningAuthTerminal(true); - setSubmitError(null); - try { - unwrap(await window.agentCompany.openConnectorAuthTerminal(connector.id)); - scheduleAuthPolling(12); - } catch (error) { - setSubmitError(error instanceof Error ? error.message : "Could not open a terminal for connector login."); - } finally { - setOpeningAuthTerminal(false); - } - })()} - loading={openingAuthTerminal} - /> -
-
- ) : null} + {error ? : null}
-
{t("conn.status")}
+
Status
- {connector.version ?? "version unknown"} + + {credential ? credential.status : "not configured"} +
-
{t("conn.capabilityMatrix")}
-
- {Object.entries(connector.capabilityMatrix).map(([key, value]) => ( -
- {key} -
- ))} +
Auth
+
+ {catalog ? AUTH_KIND_LABEL[catalog.auth.type] ?? catalog.auth.type : "Unknown"}
- undefined} readOnly /> -
- AgentCompany pins connector execution to the audited binary for this runtime. Command overrides are not accepted from the renderer. -
+
Provider id
+
{catalog?.provider ?? connector.id}
- {shouldLoadModelOptions ? ( -
- - )} -