diff --git a/.github/workflows/crocodile.yml b/.github/workflows/crocodile.yml index b99f770..18f3000 100644 --- a/.github/workflows/crocodile.yml +++ b/.github/workflows/crocodile.yml @@ -31,7 +31,7 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} with: - model: opencode/minimax-m2.5-free + model: opencode/deepseek-v4-flash-free use_github_token: true prompt: | Review this pull request: diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 8148f5e..a39ed1c 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -30,4 +30,4 @@ jobs: env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: - model: opencode/minimax-m2.5-free + model: opencode/deepseek-v4-flash-free diff --git a/src/components/DialogCustomProvider.tsx b/src/components/DialogCustomProvider.tsx index a7e2da4..0c082c9 100644 --- a/src/components/DialogCustomProvider.tsx +++ b/src/components/DialogCustomProvider.tsx @@ -143,7 +143,6 @@ function validate( if (!baseUrl.trim()) return "Base URL is required"; if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) return "Base URL must start with http:// or https://"; - if (models.length === 0) return "At least one model is required"; for (const m of models) { if (!m.id.trim()) return "All model IDs must be filled in"; if (!m.name.trim()) return "All model names must be filled in"; @@ -353,7 +352,7 @@ export function DialogCustomProvider({ firstPlaceholder="model-id" secondPlaceholder="Display Name" firstClassName="font-mono" - minEntries={1} + minEntries={0} onAdd={addModel} onRemove={removeModel} onUpdate={(idx, field, value) => updateModel(idx, field === "first" ? "id" : "name", value)} diff --git a/src/components/DialogSelectProvider.tsx b/src/components/DialogSelectProvider.tsx index 56cf219..1889a59 100644 --- a/src/components/DialogSelectProvider.tsx +++ b/src/components/DialogSelectProvider.tsx @@ -49,8 +49,6 @@ export function DialogSelectProvider({ const pop: Provider[] = []; const oth: Provider[] = []; for (const p of providers) { - // Skip already connected - if (connectedIds.has(p.id)) continue; // Filter by search if ( lowerSearch && @@ -69,7 +67,7 @@ export function DialogSelectProvider({ pop.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id)); oth.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id)); return { popular: pop, other: oth }; - }, [providers, connectedIds, lowerSearch]); + }, [providers, lowerSearch]); return (
@@ -113,7 +111,12 @@ export function DialogSelectProvider({ Popular {popular.map((p) => ( - + ))} )} @@ -125,7 +128,12 @@ export function DialogSelectProvider({ Other {other.map((p) => ( - + ))} )} diff --git a/src/components/SettingsProviders.tsx b/src/components/SettingsProviders.tsx index a3f0b13..6aec1e5 100644 --- a/src/components/SettingsProviders.tsx +++ b/src/components/SettingsProviders.tsx @@ -9,22 +9,22 @@ import type { AgentBackendId } from "@/agents"; import { AGENT_BACKEND_LABELS } from "@/agents"; -import { Loader2, Plus, Unplug } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { Loader2, Plus, Search, Unplug } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { DialogConnectProvider } from "@/components/DialogConnectProvider"; import { DialogCustomProvider } from "@/components/DialogCustomProvider"; import { DialogSelectProvider } from "@/components/DialogSelectProvider"; import { ProviderIcon } from "@/components/provider-icons/ProviderIcon"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; -import { - useAgentBackend, - useAvailableBackendIds, - useCurrentAgentBackendId, -} from "@/hooks/use-agent-backend"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { useAgentBackend, useCurrentAgentBackendId } from "@/hooks/use-agent-backend"; import { useActions, useConnectionState } from "@/hooks/use-agent-state"; +import { useOpenGuiClient } from "@/protocol/provider"; import { POPULAR_PROVIDER_IDS } from "@/lib/constants"; +import { getErrorMessage } from "@/lib/utils"; import type { AllProvidersData, ProviderAuthMethod } from "@/types/electron"; // --------------------------------------------------------------------------- @@ -57,7 +57,6 @@ export function SettingsProviders() { const { refreshProviders } = useActions(); const { activeDirectory, activeWorkspaceId } = useConnectionState(); const initialBackendId = useCurrentAgentBackendId(); - const availableBackendIds = useAvailableBackendIds(); const [backendId, setBackendId] = useState(initialBackendId); const backend = useAgentBackend(backendId); const providersApi = backend?.platform?.providers; @@ -68,34 +67,64 @@ export function SettingsProviders() { const [authMethods, setAuthMethods] = useState>({}); const [loading, setLoading] = useState(true); const [disconnecting, setDisconnecting] = useState(null); + const [confirmingDisconnect, setConfirmingDisconnect] = useState(null); + const [disconnectError, setDisconnectError] = useState<{ + providerID: string; + message: string; + } | null>(null); // Sub-dialog state const [connectProviderID, setConnectProviderID] = useState(null); const [showCustom, setShowCustom] = useState(false); const [showSelectAll, setShowSelectAll] = useState(false); + // Search + const [search, setSearch] = useState(""); + const lowerSearch = search.toLowerCase().trim(); + const isSearching = lowerSearch.length > 0; + + // Filter backends that support provider management + const openGuiClient = useOpenGuiClient(); + const providerBackendIds = useMemo( + () => + openGuiClient.agentBackends + .list() + .filter((b) => b.capabilities?.providerAuth) + .map((b) => b.id as AgentBackendId), + [openGuiClient], + ); + // Wait for auth methods to be loaded for this provider const isAuthLoading = loading || (!connectProviderID ? false : authMethods[connectProviderID] === undefined); - const refresh = useCallback(async () => { - if (!providersApi) return; - const target = { directory: scopedDirectory, workspaceId: activeWorkspaceId }; - const [allProvidersData, providerAuthMethods] = await Promise.all([ - providersApi.listAll(target), - providersApi.getAuthMethods(target), - ]); - setAllProviders(allProvidersData); - setAuthMethods(providerAuthMethods); - setLoading(false); - }, [providersApi, scopedDirectory, activeWorkspaceId]); + const refresh = useCallback( + async (showSpinner = false) => { + if (!providersApi) return; + if (showSpinner) setLoading(true); + try { + const target = { directory: scopedDirectory, workspaceId: activeWorkspaceId }; + const [allProvidersData, providerAuthMethods] = await Promise.all([ + providersApi.listAll(target), + providersApi.getAuthMethods(target), + ]); + setAllProviders(allProvidersData); + setAuthMethods(providerAuthMethods); + } finally { + if (showSpinner) setLoading(false); + } + }, + [providersApi, scopedDirectory, activeWorkspaceId], + ); useEffect(() => { - void refresh(); + void refresh(true); }, [refresh]); const handleDisconnect = async (providerID: string) => { if (!providersApi) return; + setConfirmingDisconnect(null); + setDisconnectError(null); setDisconnecting(providerID); try { const target = { directory: scopedDirectory, workspaceId: activeWorkspaceId }; @@ -103,6 +132,11 @@ export function SettingsProviders() { await providersApi.dispose(target); await refresh(); await refreshProviders(); + } catch (err) { + setDisconnectError({ + providerID, + message: getErrorMessage(err, "Failed to disconnect"), + }); } finally { setDisconnecting(null); } @@ -125,7 +159,7 @@ export function SettingsProviders() { ); } - if (loading) { + if (loading && !allProviders) { return (
@@ -144,169 +178,361 @@ export function SettingsProviders() { const providerList = Array.isArray(allProviders.all) ? allProviders.all : []; const connectedIds = Array.isArray(allProviders.connected) ? allProviders.connected : []; const connectedSet = new Set(connectedIds); - const connectedProviders = providerList.filter((p) => connectedSet.has(p.id)); + const connectedProviders = providerList + .filter((p) => connectedSet.has(p.id)) + .sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id)); const popularNotConnected = POPULAR_PROVIDER_IDS.filter((id) => !connectedSet.has(id)); - // For popular providers that aren't in the `all` list (not yet fetched from server), - // create a minimal entry const allById = new Map(providerList.map((p) => [p.id, p])); - // If a connect dialog is open, show it instead - if (connectProviderID) { - const provider = allById.get(connectProviderID); - return ( - setConnectProviderID(null)} - /> - ); - } - - if (showCustom) { - return ( - setShowCustom(false)} - /> - ); - } - - if (showSelectAll) { - return ( - { - setShowSelectAll(false); - setConnectProviderID(id); - }} - onCustom={() => { - setShowSelectAll(false); - setShowCustom(true); - }} - onBack={() => setShowSelectAll(false)} - /> - ); - } + const connectProvider = connectProviderID ? allById.get(connectProviderID) : null; + const filteredProviders = providerList + .filter( + (p) => + p.id.toLowerCase().includes(lowerSearch) || + (p.name || "").toLowerCase().includes(lowerSearch), + ) + .sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id)); return ( -
- {availableBackendIds.length > 1 && ( -
- {availableBackendIds.map((id) => ( - - ))} -
- )} - {/* Connected providers */} - {connectedProviders.length > 0 && ( -
-

- Connected -

- {connectedProviders.map((provider) => { - const isEnv = provider.source === "env"; - const isDisconnecting = disconnecting === provider.id; - return ( -
+
+ {providerBackendIds.length > 1 && ( +
+ {providerBackendIds.map((id) => ( + + ))} +
+ )} + {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="Search providers..." + className="pl-8 text-sm" + /> +
+ {isSearching ? ( +
+ {filteredProviders.map((provider) => { + const isConnected = connectedSet.has(provider.id); + const isEnv = provider.source === "env"; + const isDisconnecting = disconnecting === provider.id; + const isConfirming = confirmingDisconnect === provider.id; + const showError = disconnectError?.providerID === provider.id && !isDisconnecting; + return ( +
+
+ + {provider.name || provider.id} - -
-
- {isEnv ? ( - - from env - - ) : ( - + +
+ ) : ( + + ) ) : ( - + )} - Disconnect - - )} +
+ {showError && ( +
+

{disconnectError?.message}

+ +
+ )} +
+ ); + })} + {filteredProviders.length === 0 && ( +
+ No providers found for "{search}"
- ); - })} -
- )} + )} +
+ ) : ( + <> + {connectedProviders.length > 0 && ( +
+

+ Connected +

+ {connectedProviders.map((provider) => { + const isEnv = provider.source === "env"; + const isDisconnecting = disconnecting === provider.id; + const isConfirming = confirmingDisconnect === provider.id; + const showError = disconnectError?.providerID === provider.id && !isDisconnecting; + return ( +
+
+ +
+
+ + {provider.name || provider.id} + + +
+
+ {isEnv ? ( + + from env + + ) : isConfirming ? ( +
+ + Disconnect {provider.name || provider.id}? + + + +
+ ) : ( + + )} +
+ {showError && ( +
+

+ {disconnectError?.message} +

+ +
+ )} +
+ ); + })} +
+ )} + + {/* Popular providers (not yet connected) */} + {popularNotConnected.length > 0 && ( +
+

+ Popular +

+ {popularNotConnected.map((id) => { + const provider = allById.get(id); + return ( +
+ + + {provider?.name || id} + + +
+ ); + })} +
+ )} - {/* Popular providers (not yet connected) */} - {popularNotConnected.length > 0 && ( -
-

- Popular -

- {popularNotConnected.map((id) => { - const provider = allById.get(id); - return ( -
- - {provider?.name || id} -
- ); - })} -
- )} +
setShowSelectAll(true)} + > + + All providers +
+ + + )} +
- {/* Custom + View all */} -
-

Other

-
- - Custom provider - -
- -
-
+ {/* Connect dialog */} + { + if (!open) setConnectProviderID(null); + }} + > + + + Connect {connectProvider?.name ?? connectProviderID ?? ""} + + {connectProviderID && ( + setConnectProviderID(null)} + /> + )} + + + + {/* Custom provider dialog */} + { + if (!open) setShowCustom(false); + }} + > + + Custom provider + setShowCustom(false)} + /> + + + + {/* Select all providers dialog */} + { + if (!open) setShowSelectAll(false); + }} + > + + All providers + { + setShowSelectAll(false); + setConnectProviderID(id); + }} + onCustom={() => { + setShowSelectAll(false); + setShowCustom(true); + }} + onBack={() => setShowSelectAll(false)} + /> + + + ); }