add providers settings UI (#131)

* feat: add providers settings UI

Add the settings screens and localized copy for configuring agent and model providers on top of the provider infrastructure.

* fix: align agent provider card with merged setup API

Update the settings UI to use provider-based agent setup commands so the rebased providers PR matches the merged backend contract.

* fix: localize provider setup messaging

Move provider setup and status copy onto settings translation keys so the providers UI stays localized across supported languages.
This commit is contained in:
morgmart
2026-04-08 12:20:52 -07:00
committed by GitHub
parent d9ed14bf7a
commit 29bb56f770
8 changed files with 1626 additions and 2 deletions
@@ -0,0 +1,427 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
import { getProviderIcon } from "@/shared/ui/icons/ProviderIcons";
import { IconCheck, IconAlertTriangle, IconPlus } from "@tabler/icons-react";
import {
checkAgentInstalled,
checkAgentAuth,
installAgent,
authenticateAgent,
onAgentSetupOutput,
} from "@/features/providers/api/agentSetup";
import type { ProviderDisplayInfo } from "@/shared/types/providers";
type SetupPhase = "idle" | "checking" | "installing" | "authenticating";
interface OutputLine {
id: number;
text: string;
}
const MAX_OUTPUT_LINES = 50;
interface AgentProviderCardProps {
provider: ProviderDisplayInfo;
}
export function AgentProviderCard({ provider }: AgentProviderCardProps) {
const { t } = useTranslation(["settings", "common"]);
const [setupPhase, setSetupPhase] = useState<SetupPhase>("idle");
const [setupOutput, setSetupOutput] = useState<OutputLine[]>([]);
const [setupError, setSetupError] = useState<string | null>(null);
const [isInstalled, setIsInstalled] = useState<boolean | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const outputRef = useRef<HTMLDivElement>(null);
const outputLengthRef = useRef(0);
const lineCounterRef = useRef(0);
const isMountedRef = useRef(true);
const unlistenRef = useRef<(() => void) | null>(null);
const icon = getProviderIcon(provider.id, "size-6");
const isBuiltIn = provider.status === "built_in";
const isActive = setupPhase !== "idle";
const hasInstallCommand = !!provider.installCommand;
const hasAuthCommand = !!provider.authCommand;
const hasAuthStatusCheck = !!provider.authStatusCommand;
const hasBinary = !!provider.binaryName;
const authStorageKey = `agent-provider-auth:${provider.id}`;
const setAuthHint = useCallback(
(value: boolean) => {
if (value) {
localStorage.setItem(authStorageKey, "true");
} else {
localStorage.removeItem(authStorageKey);
}
},
[authStorageKey],
);
const getAuthHint = useCallback(() => {
return localStorage.getItem(authStorageKey) === "true";
}, [authStorageKey]);
const clearListener = useCallback(() => {
unlistenRef.current?.();
unlistenRef.current = null;
}, []);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
clearListener();
};
}, [clearListener]);
useEffect(() => {
if (!hasBinary || isBuiltIn || !provider.binaryName) return;
checkAgentInstalled(provider.id)
.then((installed) => {
if (!isMountedRef.current) return;
setIsInstalled(installed);
if (installed && provider.authStatusCommand) {
return checkAgentAuth(provider.id).then((authenticated) => {
if (!isMountedRef.current) return;
setIsAuthenticated(authenticated);
});
}
if (installed && !provider.authStatusCommand) {
setIsAuthenticated(getAuthHint() ? true : null);
}
if (!installed) {
setIsAuthenticated(null);
setAuthHint(false);
}
})
.catch(() => {
if (!isMountedRef.current) return;
setIsInstalled(false);
});
}, [
getAuthHint,
hasBinary,
isBuiltIn,
provider.id,
provider.binaryName,
provider.authStatusCommand,
setAuthHint,
]);
useEffect(() => {
if (outputRef.current && outputLengthRef.current !== setupOutput.length) {
outputLengthRef.current = setupOutput.length;
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
});
const appendOutput = useCallback((line: string) => {
lineCounterRef.current += 1;
const entry: OutputLine = { id: lineCounterRef.current, text: line };
setSetupOutput((prev) => {
const next = [...prev, entry];
return next.length > MAX_OUTPUT_LINES
? next.slice(-MAX_OUTPUT_LINES)
: next;
});
}, []);
async function handleConnect() {
setSetupError(null);
setSetupOutput([]);
lineCounterRef.current = 0;
if (hasInstallCommand && isInstalled === false) {
await runInstall();
} else if (hasAuthCommand) {
await runAuth();
}
}
async function runInstall() {
if (!provider.installCommand) return;
setSetupPhase("installing");
clearListener();
const unlisten = await onAgentSetupOutput(provider.id, appendOutput);
if (!isMountedRef.current) {
unlisten();
return;
}
unlistenRef.current = unlisten;
try {
await installAgent(provider.id);
clearListener();
if (!isMountedRef.current) return;
if (hasBinary && provider.binaryName) {
setSetupPhase("checking");
const installed = await checkAgentInstalled(provider.binaryName);
if (!isMountedRef.current) return;
setIsInstalled(installed);
if (!installed) {
setAuthHint(false);
setSetupError(
"Install finished but the CLI was not found on PATH. You may need to restart your terminal.",
);
setSetupPhase("idle");
return;
}
}
if (hasAuthCommand) {
await runAuth();
} else {
if (!isMountedRef.current) return;
setSetupPhase("idle");
}
} catch (err) {
clearListener();
if (!isMountedRef.current) return;
setSetupError(err instanceof Error ? err.message : String(err));
setSetupPhase("idle");
}
}
async function runAuth() {
if (!provider.authCommand) return;
setSetupPhase("authenticating");
setSetupOutput([]);
clearListener();
const unlisten = await onAgentSetupOutput(provider.id, appendOutput);
if (!isMountedRef.current) {
unlisten();
return;
}
unlistenRef.current = unlisten;
try {
await authenticateAgent(provider.id);
clearListener();
if (!isMountedRef.current) return;
setAuthHint(true);
setIsAuthenticated(true);
setSetupPhase("idle");
} catch (err) {
clearListener();
if (!isMountedRef.current) return;
setSetupError(err instanceof Error ? err.message : String(err));
setSetupPhase("idle");
}
}
function handleRetry() {
setSetupError(null);
void handleConnect();
}
if (provider.showOnlyWhenInstalled && isInstalled !== true) return null;
const isReady =
isBuiltIn ||
(isInstalled === true && !hasAuthCommand) ||
(isInstalled === true && isAuthenticated === true);
const needsAuth =
isInstalled === true && hasAuthCommand && isAuthenticated !== true;
const needsInstall = isInstalled === false && hasInstallCommand;
function renderStatusIndicator() {
if (isBuiltIn || isReady) {
return (
<div className="flex h-6 flex-shrink-0 items-center">
<IconCheck className="size-4 text-success" />
</div>
);
}
if (setupError) {
return (
<div className="flex h-6 flex-shrink-0 items-center">
<IconAlertTriangle className="size-4 text-danger" />
</div>
);
}
if (needsAuth && !isActive) {
return (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => void handleConnect()}
className="flex-shrink-0 text-muted-foreground"
aria-label={t("providers.agents.signInLabel", {
name: provider.displayName,
})}
>
{t("providers.agents.signIn")}
</Button>
);
}
if (needsInstall && !isActive) {
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={() => void handleConnect()}
disabled={isInstalled === null && hasBinary}
className="flex-shrink-0 text-muted-foreground"
aria-label={t("providers.agents.installLabel", {
name: provider.displayName,
})}
>
<IconPlus className="size-4" />
</Button>
);
}
return null;
}
function renderStatusText() {
if (isBuiltIn || isReady) return null;
if (setupError) return "Setup failed";
if (isInstalled === null && hasBinary) return "Checking...";
if (isInstalled === true && hasAuthStatusCheck && isAuthenticated === null)
return "Checking...";
return null;
}
function renderAction() {
if (isBuiltIn || isReady) return null;
if (isActive) return null;
if (setupError) {
return (
<Button type="button" variant="outline" size="xs" onClick={handleRetry}>
{t("common:actions.retry")}
</Button>
);
}
return null;
}
function renderSetupProgress() {
if (!isActive) return null;
const phaseLabel =
setupPhase === "installing"
? `Installing ${provider.displayName}...`
: setupPhase === "authenticating"
? "Waiting for sign-in..."
: "Verifying installation...";
const stepInfo =
setupPhase === "installing" && hasAuthCommand
? "Step 1 of 2"
: setupPhase === "authenticating" && hasInstallCommand
? "Step 2 of 2"
: null;
return (
<div className="mt-3 space-y-2 border-t pt-3">
<div className="flex items-center gap-2">
<Spinner className="size-3.5 text-accent" />
<div className="min-w-0 flex-1">
<span className="text-xs font-medium">{phaseLabel}</span>
{stepInfo && (
<span className="ml-2 text-xxs text-muted-foreground">
{stepInfo}
</span>
)}
</div>
</div>
{setupOutput.length > 0 && (
<div
ref={outputRef}
className="max-h-24 overflow-y-auto rounded-md bg-muted px-2 py-1.5 font-mono text-xxs leading-relaxed text-muted-foreground"
>
{setupOutput.map((entry) => (
<div key={entry.id}>{entry.text || "\u00A0"}</div>
))}
</div>
)}
</div>
);
}
return (
<div
className={cn(
"flex flex-col rounded-lg border bg-background p-3 transition-colors",
isActive && "border-accent/50",
)}
>
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex size-6 items-center justify-center [&>*]:size-6">
{icon}
</div>
<span className="mt-2 block text-sm">{provider.displayName}</span>
<p className="mt-1 text-xs text-muted-foreground">
{provider.description}
</p>
</div>
{renderStatusIndicator()}
</div>
{(() => {
const statusText = renderStatusText();
const action = renderAction();
if (!statusText && !action) return null;
return (
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-1.5">
{statusText && (
<>
<span
className={cn(
"size-1.5 rounded-full",
setupError
? "bg-danger"
: isActive
? "bg-accent animate-pulse"
: "bg-muted-foreground/40",
)}
/>
<span className="text-xs text-muted-foreground">
{statusText}
</span>
</>
)}
</div>
{action}
</div>
);
})()}
{renderSetupProgress()}
{setupError && !isActive && (
<div className="mt-3 space-y-2 border-t pt-3">
<p className="text-xs text-danger">{setupError}</p>
{setupOutput.length > 0 && (
<div className="max-h-24 overflow-y-auto rounded-md bg-muted px-2 py-1.5 font-mono text-xxs leading-relaxed text-muted-foreground">
{setupOutput.map((entry) => (
<div key={entry.id}>{entry.text || "\u00A0"}</div>
))}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,263 @@
import type { RefObject } from "react";
import { useTranslation } from "react-i18next";
import { AsyncButton } from "@/shared/ui/async-button";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { IconLoader2 } from "@tabler/icons-react";
import type {
ProviderField,
ProviderFieldValue,
ProviderSetupMethod,
} from "@/shared/types/providers";
import {
resolveFieldValue,
getDisplayValue,
renderSetupMessage,
renderInlineCodeMessage,
} from "./modelProviderHelpers";
interface ConnectedFieldsPanelProps {
panelRef: RefObject<HTMLDivElement | null>;
fields: ProviderField[];
fieldValueMap: Map<string, ProviderFieldValue>;
editingKey: string | null;
draftValues: Record<string, string>;
saving: boolean;
showSavedState: boolean;
error: string;
setupMessage: string | null;
onStartEdit: (key: string) => void;
onCancelEdit: (field: ProviderField) => void;
onDraftChange: (key: string, value: string) => void;
onSaveField: (field: ProviderField) => void;
onRemove: () => void;
}
export function ConnectedFieldsPanel({
panelRef,
fields,
fieldValueMap,
editingKey,
draftValues,
saving,
showSavedState,
error,
setupMessage,
onStartEdit,
onCancelEdit,
onDraftChange,
onSaveField,
onRemove,
}: ConnectedFieldsPanelProps) {
const { t } = useTranslation(["settings", "common"]);
return (
<div
ref={panelRef}
tabIndex={-1}
className="focus-override mx-3 space-y-3 rounded-b-lg border-x border-b px-3 py-3 outline-none"
>
{fields.map((field) => {
const isEditing = editingKey === field.key;
return (
<div
key={field.key}
className="space-y-2 rounded-md border border-border px-3 py-2.5"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm">{field.label}</p>
{!isEditing && (
<p className="truncate text-xs text-muted-foreground">
{getDisplayValue(field, fieldValueMap, t)}
</p>
)}
</div>
{!isEditing && (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onStartEdit(field.key)}
disabled={saving}
className="text-muted-foreground"
>
{resolveFieldValue(field, fieldValueMap).isSet
? "Edit"
: "Add"}
</Button>
)}
</div>
{isEditing && (
<div className="flex items-center gap-2">
<Input
type={field.secret ? "password" : "text"}
value={draftValues[field.key] ?? ""}
placeholder={
field.secret &&
resolveFieldValue(field, fieldValueMap).isSet
? getDisplayValue(field, fieldValueMap, t)
: field.placeholder
}
onChange={(event) =>
onDraftChange(field.key, event.target.value)
}
onKeyDown={(event) => {
if (event.key === "Enter") {
onSaveField(field);
}
}}
disabled={saving}
className="h-8 flex-1 text-xs"
/>
<Button
type="button"
size="sm"
onClick={() => onSaveField(field)}
disabled={saving || !(draftValues[field.key]?.trim() ?? "")}
className="h-8"
>
{saving ? (
<IconLoader2 className="size-3 animate-spin" />
) : null}
{t("common:actions.save")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onCancelEdit(field)}
disabled={saving}
className="h-8"
>
{t("common:actions.cancel")}
</Button>
</div>
)}
</div>
);
})}
<div className="flex justify-end gap-2">
{showSavedState ? (
<Button type="button" variant="secondary" size="sm" disabled>
{t("providers.saved")}
</Button>
) : null}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onRemove()}
disabled={saving}
className="text-danger hover:text-danger"
>
{saving ? <IconLoader2 className="size-3 animate-spin" /> : null}
{t("providers.disconnect")}
</Button>
</div>
{renderSetupMessage(setupMessage)}
{error && <p className="text-xs text-danger">{error}</p>}
</div>
);
}
interface SetupFieldsPanelProps {
panelRef: RefObject<HTMLDivElement | null>;
fields: ProviderField[];
fieldValueMap: Map<string, ProviderFieldValue>;
draftValues: Record<string, string>;
saving: boolean;
showSavedState: boolean;
error: string;
setupMethod: ProviderSetupMethod;
setupMessage: string | null;
fieldSetupDescription: string | null;
isConnected: boolean;
onDraftChange: (key: string, value: string) => void;
onSaveSetup: () => void;
}
export function SetupFieldsPanel({
panelRef,
fields,
fieldValueMap,
draftValues,
saving,
showSavedState,
error,
setupMethod,
setupMessage,
fieldSetupDescription,
isConnected,
onDraftChange,
onSaveSetup,
}: SetupFieldsPanelProps) {
const { t } = useTranslation(["settings", "common"]);
return (
<div
ref={panelRef}
tabIndex={-1}
className="focus-override mx-3 space-y-3 rounded-b-lg border-x border-b px-3 py-3 outline-none"
>
{!isConnected && fieldSetupDescription ? (
<p className="text-xs text-muted-foreground">{fieldSetupDescription}</p>
) : null}
{fields.map((field) => {
const fieldValue = resolveFieldValue(field, fieldValueMap);
return (
<div key={field.key} className="space-y-1">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-medium text-foreground">
{field.label}
</span>
{field.required && (
<span className="text-xxs text-muted-foreground">
{t("common:labels.required")}
</span>
)}
</div>
<Input
type={field.secret ? "password" : "text"}
value={draftValues[field.key] ?? ""}
placeholder={
field.secret && fieldValue.isSet
? getDisplayValue(field, fieldValueMap, t)
: field.placeholder
}
onChange={(event) => onDraftChange(field.key, event.target.value)}
disabled={saving}
className="h-8 text-xs"
/>
</div>
);
})}
<div className="flex justify-end">
<AsyncButton
type="button"
state={saving ? "pending" : showSavedState ? "success" : "idle"}
idleLabel="Save"
pendingLabel="Saving..."
successLabel="Saved"
pendingVisual="text"
pendingDelayMs={250}
size="sm"
onClick={() => onSaveSetup()}
disabled={saving || showSavedState}
className="h-8"
/>
</div>
{setupMethod === "host_with_oauth_fallback"
? renderInlineCodeMessage(
t("providers.models.setup.hostWithOauthFallbackTerminal"),
)
: null}
{setupMethod === "cloud_credentials" && setupMessage
? renderSetupMessage(setupMessage)
: null}
{error && <p className="text-xs text-danger">{error}</p>}
</div>
);
}
@@ -0,0 +1,463 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/shared/ui/button";
import { Skeleton } from "@/shared/ui/skeleton";
import { Spinner } from "@/shared/ui/spinner";
import {
getProviderIcon,
formatProviderLabel,
} from "@/shared/ui/icons/ProviderIcons";
import { IconCheck } from "@tabler/icons-react";
import {
authenticateModelProvider,
onModelSetupOutput,
} from "@/features/providers/api/modelSetup";
import type {
ProviderDisplayInfo,
ProviderField,
ProviderFieldValue,
} from "@/shared/types/providers";
import {
MAX_SETUP_OUTPUT_LINES,
type SetupOutputLine,
resolveFieldValue,
createDraftValues,
getSetupMessage,
getNativeConnectDescription,
getFieldSetupDescription,
renderSetupMessage,
} from "./modelProviderHelpers";
import { ConnectedFieldsPanel, SetupFieldsPanel } from "./ModelProviderPanels";
interface ModelProviderRowProps {
provider: ProviderDisplayInfo;
onGetConfig: (providerId: string) => Promise<ProviderFieldValue[]>;
onSaveField: (key: string, value: string, isSecret: boolean) => Promise<void>;
onRemoveConfig?: () => Promise<void>;
onCompleteNativeSetup: () => Promise<void>;
saving?: boolean;
}
export function ModelProviderRow({
provider,
onGetConfig,
onSaveField,
onRemoveConfig,
onCompleteNativeSetup,
saving = false,
}: ModelProviderRowProps) {
const { t } = useTranslation("settings");
const [expanded, setExpanded] = useState(false);
const [configValues, setConfigValues] = useState<ProviderFieldValue[]>([]);
const [draftValues, setDraftValues] = useState<Record<string, string>>({});
const [editingKey, setEditingKey] = useState<string | null>(null);
const [loadingConfig, setLoadingConfig] = useState(false);
const [error, setError] = useState("");
const [authenticating, setAuthenticating] = useState(false);
const [setupOutput, setSetupOutput] = useState<SetupOutputLine[]>([]);
const [setupError, setSetupError] = useState("");
const [showSavedState, setShowSavedState] = useState(false);
const [preserveSetupLayout, setPreserveSetupLayout] = useState(false);
const setupLineCounter = useRef(0);
const hasLoadedConfig = useRef(false);
const shouldRestorePanelFocus = useRef(false);
const panelRef = useRef<HTMLDivElement | null>(null);
const icon = getProviderIcon(provider.id, "size-4");
const fields = provider.fields ?? [];
const hasFields = fields.length > 0;
const supportsNativeConnect = !!provider.nativeConnectQuery;
const isConnected =
provider.status === "connected" || provider.status === "built_in";
const fieldValueMap = useMemo(
() => new Map(configValues.map((value) => [value.key, value])),
[configValues],
);
const loadConfig = useCallback(
async ({ showSkeleton = false }: { showSkeleton?: boolean } = {}) => {
if (!hasFields) return;
if (showSkeleton) {
setLoadingConfig(true);
}
try {
const nextValues = await onGetConfig(provider.id);
hasLoadedConfig.current = true;
setConfigValues(nextValues);
setDraftValues(createDraftValues(fields, nextValues));
setError("");
} catch (nextError) {
setError(
nextError instanceof Error
? nextError.message
: "Failed to load provider settings",
);
} finally {
if (showSkeleton) {
setLoadingConfig(false);
}
}
},
[fields, hasFields, onGetConfig, provider.id],
);
useEffect(() => {
if (expanded && hasFields) {
void loadConfig({ showSkeleton: !hasLoadedConfig.current });
}
}, [expanded, hasFields, loadConfig]);
useEffect(() => {
if (isConnected) {
setAuthenticating(false);
setSetupError("");
}
}, [isConnected]);
useLayoutEffect(() => {
if (!shouldRestorePanelFocus.current) {
return;
}
shouldRestorePanelFocus.current = false;
panelRef.current?.focus({ preventScroll: true });
});
function appendSetupOutput(line: string) {
setupLineCounter.current += 1;
setSetupOutput((current) =>
[
...current,
{
id: setupLineCounter.current,
text: line,
},
].slice(-MAX_SETUP_OUTPUT_LINES),
);
}
async function runNativeConnect() {
if (!provider.nativeConnectQuery) {
return;
}
setExpanded(true);
setAuthenticating(true);
setSetupError("");
setSetupOutput([]);
setupLineCounter.current = 0;
setEditingKey(null);
setError("");
setShowSavedState(false);
setPreserveSetupLayout(false);
const unlisten = await onModelSetupOutput(provider.id, appendSetupOutput);
try {
await authenticateModelProvider(provider.id, provider.nativeConnectQuery);
await onCompleteNativeSetup();
} catch (nextError) {
setSetupError(
nextError instanceof Error
? nextError.message
: "Failed to complete sign-in",
);
} finally {
unlisten();
setAuthenticating(false);
}
}
function handleToggle() {
setExpanded((current) => {
if (current) {
setShowSavedState(false);
setPreserveSetupLayout(false);
}
return !current;
});
setEditingKey(null);
setError("");
setSetupError("");
}
function handleStartEdit(key: string) {
setEditingKey(key);
setError("");
setShowSavedState(false);
}
function handleCancelEdit(field: ProviderField) {
setDraftValues((current) => ({
...current,
[field.key]: field.secret
? ""
: (resolveFieldValue(field, fieldValueMap).value ?? ""),
}));
setEditingKey(null);
setError("");
}
function handleDraftChange(key: string, value: string) {
setShowSavedState(false);
setDraftValues((current) => ({ ...current, [key]: value }));
}
async function handleSaveField(field: ProviderField) {
const nextValue = draftValues[field.key]?.trim() ?? "";
if (!nextValue) {
setError(`Enter a value for ${field.label}`);
return;
}
setError("");
try {
shouldRestorePanelFocus.current = true;
await onSaveField(field.key, nextValue, field.secret);
await loadConfig();
setEditingKey(null);
setShowSavedState(true);
} catch (nextError) {
setError(
nextError instanceof Error ? nextError.message : "Failed to save",
);
}
}
async function handleSaveSetup() {
const missingLabels = fields
.filter((field) => {
if (!field.required) {
return false;
}
const currentValue = resolveFieldValue(field, fieldValueMap);
const nextValue = draftValues[field.key]?.trim() ?? "";
return !currentValue.isSet && !nextValue;
})
.map((field) => field.label);
if (missingLabels.length > 0) {
setError(`Fill in ${missingLabels.join(", ")}`);
return;
}
const fieldsToSave = fields.filter((field) => {
const currentValue = resolveFieldValue(field, fieldValueMap);
const nextValue = draftValues[field.key]?.trim() ?? "";
if (!nextValue) {
return false;
}
if (field.secret) {
return true;
}
return nextValue !== (currentValue.value ?? "");
});
if (fieldsToSave.length === 0) {
setError("");
return;
}
setError("");
try {
for (const field of fieldsToSave) {
const nextValue = draftValues[field.key]?.trim() ?? "";
await onSaveField(field.key, nextValue, field.secret);
}
await loadConfig();
setShowSavedState(true);
setPreserveSetupLayout(true);
} catch (nextError) {
setError(
nextError instanceof Error ? nextError.message : "Failed to save",
);
}
}
async function handleRemove() {
try {
shouldRestorePanelFocus.current = true;
await onRemoveConfig?.();
await loadConfig();
setEditingKey(null);
setError("");
setShowSavedState(false);
setPreserveSetupLayout(false);
} catch (nextError) {
setError(
nextError instanceof Error ? nextError.message : "Failed to remove",
);
}
}
function renderExpandedContent() {
if (!expanded) return null;
const setupMessage = getSetupMessage(
provider.setupMethod,
isConnected,
supportsNativeConnect,
t,
);
const nativeConnectDescription = getNativeConnectDescription(
provider.setupMethod,
t,
);
const fieldSetupDescription = getFieldSetupDescription(
provider.setupMethod,
t,
);
if (loadingConfig && hasFields) {
return (
<div
ref={panelRef}
tabIndex={-1}
className="focus-override mx-3 space-y-3 rounded-b-lg border-x border-b px-3 py-3 outline-none"
>
<Skeleton className="h-12 w-full rounded-md" />
<Skeleton className="h-12 w-full rounded-md" />
</div>
);
}
if (supportsNativeConnect && !hasFields) {
return (
<div
ref={panelRef}
tabIndex={-1}
className="focus-override mx-3 space-y-3 rounded-b-lg border-x border-b px-3 py-3 outline-none"
>
{!isConnected && nativeConnectDescription ? (
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{nativeConnectDescription}
</p>
<Button
type="button"
size="sm"
onClick={() => void runNativeConnect()}
disabled={authenticating}
className="shrink-0"
>
{authenticating ? (
<Spinner className="size-3.5 text-current" />
) : null}
{setupError ? "Retry" : "Connect"}
</Button>
</div>
) : (
renderSetupMessage(setupMessage)
)}
{authenticating ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Spinner className="size-3.5 text-accent" />
<span>{t("providers.waitingForSignIn")}</span>
</div>
) : null}
{setupOutput.length > 0 ? (
<div className="space-y-1 rounded-md bg-muted px-3 py-2 font-mono text-xxs text-muted-foreground">
{setupOutput.map((line) => (
<p key={line.id}>{line.text}</p>
))}
</div>
) : null}
{setupError ? (
<p className="text-xs text-danger">{setupError}</p>
) : null}
</div>
);
}
if (hasFields && isConnected && !preserveSetupLayout) {
return (
<ConnectedFieldsPanel
panelRef={panelRef}
fields={fields}
fieldValueMap={fieldValueMap}
editingKey={editingKey}
draftValues={draftValues}
saving={saving}
showSavedState={showSavedState}
error={error}
setupMessage={setupMessage}
onStartEdit={handleStartEdit}
onCancelEdit={handleCancelEdit}
onDraftChange={handleDraftChange}
onSaveField={(field) => void handleSaveField(field)}
onRemove={() => void handleRemove()}
/>
);
}
if (hasFields) {
return (
<SetupFieldsPanel
panelRef={panelRef}
fields={fields}
fieldValueMap={fieldValueMap}
draftValues={draftValues}
saving={saving}
showSavedState={showSavedState}
error={error}
setupMethod={provider.setupMethod}
setupMessage={setupMessage}
fieldSetupDescription={fieldSetupDescription}
isConnected={isConnected}
onDraftChange={handleDraftChange}
onSaveSetup={() => void handleSaveSetup()}
/>
);
}
return (
<div
ref={panelRef}
tabIndex={-1}
className="focus-override mx-3 space-y-2 rounded-b-lg border-x border-b px-3 py-3 outline-none"
>
{renderSetupMessage(setupMessage)}
</div>
);
}
return (
<div>
<button
type="button"
onClick={handleToggle}
aria-expanded={expanded}
disabled={authenticating}
className="flex w-full items-center gap-3 rounded-lg border border-border px-3 py-2.5 text-left transition-colors hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:hover:bg-transparent"
>
<div className="flex size-6 flex-shrink-0 items-center justify-center">
{icon || (
<span className="text-xs font-medium text-muted-foreground">
{formatProviderLabel(provider.id).charAt(0)}
</span>
)}
</div>
<span className="min-w-0 flex-1 text-sm">{provider.displayName}</span>
{isConnected ? (
<IconCheck className="size-4 flex-shrink-0 text-success" />
) : null}
{!isConnected && authenticating ? (
<Spinner className="size-3.5 flex-shrink-0 text-accent" />
) : null}
</button>
{renderExpandedContent()}
</div>
);
}
@@ -0,0 +1,223 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/shared/ui/button";
import { Separator } from "@/shared/ui/separator";
import { Skeleton } from "@/shared/ui/skeleton";
import { IconChevronDown, IconRefresh } from "@tabler/icons-react";
import {
getAgentProviders,
getModelProviders,
} from "@/features/providers/providerCatalog";
import { useCredentials } from "@/features/providers/hooks/useCredentials";
import { AgentProviderCard } from "./AgentProviderCard";
import { ModelProviderRow } from "./ModelProviderRow";
import type {
ProviderDisplayInfo,
ProviderSetupStatus,
ProviderCatalogEntry,
} from "@/shared/types/providers";
function resolveStatus(
entry: ProviderCatalogEntry,
configuredIds: Set<string>,
): ProviderSetupStatus {
if (entry.id === "goose") return "built_in";
if (entry.category === "agent") return "not_installed";
if (configuredIds.has(entry.id)) return "connected";
return "not_configured";
}
function toDisplayInfo(
entries: ProviderCatalogEntry[],
configuredIds: Set<string>,
): ProviderDisplayInfo[] {
return entries.map((entry) => ({
...entry,
status: resolveStatus(entry, configuredIds),
}));
}
export function ProvidersSettings() {
const { t } = useTranslation(["settings", "common"]);
const [showAllModels, setShowAllModels] = useState(false);
const [modelOrder, setModelOrder] = useState<string[] | null>(null);
const {
configuredIds,
loading,
saving,
needsRestart,
getConfig,
save,
remove,
restart,
completeNativeSetup,
} = useCredentials();
const agents = useMemo(
() => toDisplayInfo(getAgentProviders(), configuredIds),
[configuredIds],
);
const allModels = useMemo(
() => toDisplayInfo(getModelProviders(), configuredIds),
[configuredIds],
);
const sortedModels = useMemo(() => {
return [...allModels].sort((a, b) => {
const connected = (p: ProviderDisplayInfo) =>
p.status === "connected" || p.status === "built_in";
if (connected(a) && !connected(b)) return -1;
if (!connected(a) && connected(b)) return 1;
return 0;
});
}, [allModels]);
useEffect(() => {
if (!loading && modelOrder === null) {
setModelOrder(sortedModels.map((model) => model.id));
}
}, [loading, modelOrder, sortedModels]);
const orderedModels = useMemo(() => {
if (!modelOrder) {
return sortedModels;
}
const orderIndex = new Map(
modelOrder.map((modelId, index) => [modelId, index]),
);
return [...allModels].sort((a, b) => {
const aIndex = orderIndex.get(a.id);
const bIndex = orderIndex.get(b.id);
if (aIndex !== undefined && bIndex !== undefined) {
return aIndex - bIndex;
}
if (aIndex !== undefined) {
return -1;
}
if (bIndex !== undefined) {
return 1;
}
return a.displayName.localeCompare(b.displayName);
});
}, [allModels, modelOrder, sortedModels]);
const promotedModels = orderedModels.filter(
(m) => m.tier === "promoted" || m.tier === "standard",
);
const advancedModels = orderedModels.filter((m) => m.tier === "advanced");
const visibleModels = showAllModels ? orderedModels : promotedModels;
if (loading) {
return (
<div className="space-y-6">
<div>
<Skeleton className="h-5 w-32" />
<Skeleton className="mt-2 h-4 w-64" />
</div>
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-28" />
<Skeleton className="h-28" />
<Skeleton className="h-28" />
<Skeleton className="h-28" />
</div>
</div>
);
}
return (
<div>
<h3 className="text-lg font-semibold font-display tracking-tight">
{t("providers.title")}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{t("providers.description")}
</p>
{needsRestart && (
<div className="mt-3 flex items-center gap-3 rounded-lg border border-accent bg-background-accent/30 px-3 py-2.5">
<p className="flex-1 text-sm">{t("providers.restartMessage")}</p>
<Button type="button" size="sm" onClick={() => void restart()}>
<IconRefresh className="size-3.5" />
{t("providers.restartButton")}
</Button>
</div>
)}
<Separator className="my-4" />
<section>
<div className="mb-3">
<h4 className="text-sm font-semibold">
{t("providers.agents.title")}
</h4>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("providers.agents.description")}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
{agents.map((agent) => (
<AgentProviderCard key={agent.id} provider={agent} />
))}
</div>
</section>
<Separator className="my-6" />
<section>
<div className="mb-3">
<h4 className="text-sm font-semibold">
{t("providers.models.title")}
</h4>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("providers.models.description")}
</p>
</div>
<div className="space-y-2">
{visibleModels.map((model) => (
<ModelProviderRow
key={model.id}
provider={model}
onGetConfig={getConfig}
onSaveField={save}
onRemoveConfig={() => remove(model.id)}
onCompleteNativeSetup={completeNativeSetup}
saving={saving}
/>
))}
</div>
{!showAllModels && advancedModels.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAllModels(true)}
className="mt-2 w-full text-muted-foreground"
>
{t("providers.showMore", { count: advancedModels.length })}
<IconChevronDown className="size-3" />
</Button>
)}
{showAllModels && advancedModels.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAllModels(false)}
className="mt-2 w-full text-muted-foreground"
>
{t("providers.showFewer")}
</Button>
)}
</section>
</div>
);
}
@@ -33,8 +33,10 @@ import {
Stethoscope,
X,
} from "lucide-react";
import { IconPlug } from "@tabler/icons-react";
import { AppearanceSettings } from "./AppearanceSettings";
import { DoctorSettings } from "./DoctorSettings";
import { ProvidersSettings } from "./ProvidersSettings";
import {
listArchivedProjects,
restoreProject,
@@ -49,6 +51,7 @@ import type { Session } from "@/shared/types/chat";
const NAV_ITEMS = [
{ id: "appearance", labelKey: "nav.appearance", icon: Palette },
{ id: "providers", labelKey: "nav.providers", icon: IconPlug },
{ id: "general", labelKey: "nav.general", icon: Settings2 },
{ id: "projects", labelKey: "nav.projects", icon: FolderKanban },
{ id: "chats", labelKey: "nav.chats", icon: MessageSquare },
@@ -238,6 +241,7 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
}}
>
{activeSection === "appearance" && <AppearanceSettings />}
{activeSection === "providers" && <ProvidersSettings />}
{activeSection === "doctor" && <DoctorSettings />}
{activeSection === "general" && (
<div className="space-y-6">
@@ -0,0 +1,152 @@
import type {
ProviderField,
ProviderFieldValue,
ProviderSetupMethod,
} from "@/shared/types/providers";
export const MAX_SETUP_OUTPUT_LINES = 8;
export interface SetupOutputLine {
id: number;
text: string;
}
export function getDefaultFieldValue(field: ProviderField): ProviderFieldValue {
return {
key: field.key,
value: null,
isSet: false,
isSecret: field.secret,
required: field.required,
};
}
export function resolveFieldValue(
field: ProviderField,
fieldValueMap: Map<string, ProviderFieldValue>,
): ProviderFieldValue {
return fieldValueMap.get(field.key) ?? getDefaultFieldValue(field);
}
export function getDisplayValue(
field: ProviderField,
fieldValueMap: Map<string, ProviderFieldValue>,
t: (key: string) => string,
): string {
const fieldValue = resolveFieldValue(field, fieldValueMap);
if (!fieldValue.isSet) return t("providers.models.notSet");
return fieldValue.value ?? t("providers.saved");
}
export function createDraftValues(
fields: ProviderField[],
values: ProviderFieldValue[],
): Record<string, string> {
const valueMap = new Map(values.map((value) => [value.key, value]));
return Object.fromEntries(
fields.map((field) => {
const currentValue = valueMap.get(field.key);
if (field.secret) {
return [field.key, ""];
}
return [field.key, currentValue?.value ?? ""];
}),
);
}
export function getSetupMessage(
setupMethod: ProviderSetupMethod,
isConnected: boolean,
supportsNativeConnect: boolean,
t: (key: string) => string,
): string | null {
if (isConnected) {
switch (setupMethod) {
case "oauth_device_code":
return t("providers.models.setup.connected.oauthDeviceCode");
case "oauth_browser":
return t("providers.models.setup.connected.oauthBrowser");
case "cloud_credentials":
return t("providers.models.setup.connected.cloudCredentials");
case "local":
return t("providers.models.setup.connected.local");
default:
return null;
}
}
switch (setupMethod) {
case "oauth_browser":
case "oauth_device_code":
return supportsNativeConnect
? t("providers.models.setup.pending.oauthGuided")
: t("providers.models.setup.pending.oauthTerminal");
case "cloud_credentials":
return t("providers.models.setup.pending.cloudCredentials");
case "local":
return t("providers.models.setup.pending.local");
default:
return null;
}
}
export function getNativeConnectDescription(
setupMethod: ProviderSetupMethod,
t: (key: string) => string,
): string | null {
switch (setupMethod) {
case "oauth_device_code":
case "oauth_browser":
return t("providers.models.setup.nativeConnectDescription");
default:
return null;
}
}
export function getFieldSetupDescription(
setupMethod: ProviderSetupMethod,
t: (key: string) => string,
): string | null {
switch (setupMethod) {
case "single_api_key":
return t("providers.models.setup.fieldDescription.singleApiKey");
case "config_fields":
return t("providers.models.setup.fieldDescription.configFields");
case "host_with_oauth_fallback":
return t("providers.models.setup.fieldDescription.hostWithOauthFallback");
case "cloud_credentials":
return t("providers.models.setup.fieldDescription.cloudCredentials");
default:
return null;
}
}
export function renderInlineCodeMessage(message: string) {
const command = "`goose configure`";
if (!message.includes(command)) {
return <p className="text-xs text-muted-foreground">{message}</p>;
}
const [before, after] = message.split(command);
return (
<p className="text-xs text-muted-foreground">
{before}
<code className="rounded bg-muted px-1 py-0.5 text-xxs">
goose configure
</code>
{after}
</p>
);
}
export function renderSetupMessage(message: string | null) {
if (!message) {
return null;
}
if (message.includes("`goose configure`")) {
return renderInlineCodeMessage(message);
}
return <p className="text-xs text-muted-foreground">{message}</p>;
}
@@ -84,7 +84,8 @@
"chats": "Chats",
"doctor": "Doctor",
"general": "General",
"projects": "Projects"
"projects": "Projects",
"providers": "Providers"
},
"projects": {
"description": "Manage your projects.",
@@ -92,5 +93,50 @@
"sectionTitle": "Archived Projects",
"title": "Projects"
},
"providers": {
"agents": {
"description": "Agents handle your requests using their own tools and models",
"installLabel": "Install {{name}}",
"signIn": "Sign in",
"signInLabel": "Sign in to {{name}}",
"title": "Agents"
},
"description": "Connect agents and AI models to use with Goose",
"disconnect": "Disconnect",
"models": {
"description": "AI models that power Goose. Expand a provider to review what it needs. Connect signs in with an existing account, while Set up saves API keys or other provider settings.",
"notSet": "Not set",
"setup": {
"connected": {
"cloudCredentials": "Connected through your cloud credentials.",
"local": "Running locally.",
"oauthBrowser": "Connected through Goose sign-in.",
"oauthDeviceCode": "Connected through Goose device-code sign-in."
},
"fieldDescription": {
"cloudCredentials": "Set up saves any provider details Goose needs here. Your actual authentication still comes from your cloud credentials.",
"configFields": "Set up saves the provider details Goose needs, such as an API key, endpoint, or model settings.",
"hostWithOauthFallback": "Set up saves the host details Goose needs here. You can add a token now, or leave it blank and sign in afterward from Goose.",
"singleApiKey": "Set up saves the API key Goose needs for this provider."
},
"hostWithOauthFallbackTerminal": "Leave Access Token blank, save your host URL, then run `goose configure` in your terminal to sign in.",
"nativeConnectDescription": "Sign in with your existing account.",
"pending": {
"cloudCredentials": "Configure your cloud credentials in your terminal environment before using this provider.",
"local": "This provider runs locally and does not need saved settings here.",
"oauthGuided": "Goose will guide you through sign-in from here.",
"oauthTerminal": "Run `goose configure` in your terminal to finish sign-in."
}
},
"title": "Models"
},
"restartButton": "Restart now",
"restartMessage": "Restart to apply credential changes.",
"saved": "Saved",
"showFewer": "Show fewer",
"showMore": "Show {{count}} more providers",
"title": "Providers",
"waitingForSignIn": "Waiting for sign-in..."
},
"title": "Settings"
}
@@ -84,7 +84,8 @@
"chats": "Chats",
"doctor": "Diagnóstico",
"general": "General",
"projects": "Proyectos"
"projects": "Proyectos",
"providers": "Proveedores"
},
"projects": {
"description": "Administra tus proyectos.",
@@ -92,5 +93,50 @@
"sectionTitle": "Proyectos archivados",
"title": "Proyectos"
},
"providers": {
"agents": {
"description": "Los agentes manejan tus solicitudes usando sus propias herramientas y modelos",
"installLabel": "Instalar {{name}}",
"signIn": "Iniciar sesión",
"signInLabel": "Iniciar sesión en {{name}}",
"title": "Agentes"
},
"description": "Conecta agentes y modelos de IA para usar con Goose",
"disconnect": "Desconectar",
"models": {
"description": "Modelos de IA que alimentan a Goose. Expande un proveedor para revisar lo que necesita. Conectar inicia sesión con una cuenta existente, mientras que Configurar guarda claves API u otros ajustes del proveedor.",
"notSet": "No configurado",
"setup": {
"connected": {
"cloudCredentials": "Conectado mediante tus credenciales de nube.",
"local": "Ejecutándose localmente.",
"oauthBrowser": "Conectado mediante el inicio de sesión de Goose.",
"oauthDeviceCode": "Conectado mediante el inicio de sesión por código de dispositivo de Goose."
},
"fieldDescription": {
"cloudCredentials": "La configuración guarda aquí cualquier detalle del proveedor que Goose necesite. Tu autenticación real sigue viniendo de tus credenciales de nube.",
"configFields": "La configuración guarda los detalles del proveedor que Goose necesita, como una clave API, un endpoint o ajustes del modelo.",
"hostWithOauthFallback": "La configuración guarda aquí los detalles del host que Goose necesita. Puedes añadir un token ahora o dejarlo en blanco e iniciar sesión después desde Goose.",
"singleApiKey": "La configuración guarda la clave API que Goose necesita para este proveedor."
},
"hostWithOauthFallbackTerminal": "Deja en blanco el token de acceso, guarda la URL del host y luego ejecuta `goose configure` en tu terminal para iniciar sesión.",
"nativeConnectDescription": "Inicia sesión con tu cuenta existente.",
"pending": {
"cloudCredentials": "Configura tus credenciales de nube en el entorno de tu terminal antes de usar este proveedor.",
"local": "Este proveedor se ejecuta localmente y no necesita ajustes guardados aquí.",
"oauthGuided": "Goose te guiará por el inicio de sesión desde aquí.",
"oauthTerminal": "Ejecuta `goose configure` en tu terminal para terminar de iniciar sesión."
}
},
"title": "Modelos"
},
"restartButton": "Reiniciar ahora",
"restartMessage": "Reinicia para aplicar los cambios de credenciales.",
"saved": "Guardado",
"showFewer": "Mostrar menos",
"showMore": "Mostrar {{count}} proveedores más",
"title": "Proveedores",
"waitingForSignIn": "Esperando inicio de sesión..."
},
"title": "Configuración"
}