Skip to content
This repository was archived by the owner on Apr 14, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions src/features/providers/hooks/useCredentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
deleteProviderConfig,
type ProviderStatus,
checkAllProviderStatus,
restartApp,
} from "@/features/providers/api/credentials";
import type { ProviderFieldValue } from "@/shared/types/providers";

Expand All @@ -17,7 +16,6 @@ interface UseCredentialsReturn {
getConfig: (providerId: string) => Promise<ProviderFieldValue[]>;
save: (key: string, value: string) => Promise<void>;
remove: (providerId: string) => Promise<void>;
restart: () => Promise<void>;
completeNativeSetup: () => Promise<void>;
}

Expand Down Expand Up @@ -77,10 +75,6 @@ export function useCredentials(): UseCredentialsReturn {
[refreshStatuses],
);

const restart = useCallback(async () => {
await restartApp();
}, []);

const completeNativeSetup = useCallback(async () => {
await refreshStatuses();
setNeedsRestart(true);
Expand All @@ -94,7 +88,6 @@ export function useCredentials(): UseCredentialsReturn {
getConfig,
save,
remove,
restart,
completeNativeSetup,
};
}
35 changes: 32 additions & 3 deletions src/features/settings/ui/AgentProviderCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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 { Tooltip, TooltipTrigger, TooltipContent } from "@/shared/ui/tooltip";
import {
checkAgentInstalled,
checkAgentAuth,
Expand All @@ -25,9 +26,13 @@ const MAX_OUTPUT_LINES = 50;

interface AgentProviderCardProps {
provider: ProviderDisplayInfo;
onScrollToModels?: () => void;
}

export function AgentProviderCard({ provider }: AgentProviderCardProps) {
export function AgentProviderCard({
provider,
onScrollToModels,
}: AgentProviderCardProps) {
const { t } = useTranslation(["settings", "common"]);
const [setupPhase, setSetupPhase] = useState<SetupPhase>("idle");
const [setupOutput, setSetupOutput] = useState<OutputLine[]>([]);
Expand All @@ -41,7 +46,9 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
const unlistenRef = useRef<(() => void) | null>(null);

const icon = getProviderIcon(provider.id, "size-6");
const isBuiltIn = provider.status === "built_in";
const isBuiltIn =
provider.status === "built_in" || provider.status === "needs_model";
const needsModelProvider = provider.status === "needs_model";
const isActive = setupPhase !== "idle";
const hasInstallCommand = !!provider.installCommand;
const hasAuthCommand = !!provider.authCommand;
Expand Down Expand Up @@ -224,14 +231,36 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
if (provider.showOnlyWhenInstalled && isInstalled !== true) return null;

const isReady =
isBuiltIn ||
(isBuiltIn && !needsModelProvider) ||
(isInstalled === true && !hasAuthCommand) ||
(isInstalled === true && isAuthenticated === true);
const needsAuth =
isInstalled === true && hasAuthCommand && isAuthenticated !== true;
const needsInstall = isInstalled === false && hasInstallCommand;

function renderStatusIndicator() {
if (needsModelProvider && onScrollToModels) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onScrollToModels}
className="flex-shrink-0 text-muted-foreground"
aria-label={t("providers.agents.connectModelLabel")}
>
<IconPlus className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left" showArrow={false}>
{t("providers.agents.connectModelTooltip")}
</TooltipContent>
</Tooltip>
);
}

if (isBuiltIn || isReady) {
return (
<div className="flex h-6 flex-shrink-0 items-center">
Expand Down
7 changes: 1 addition & 6 deletions src/features/settings/ui/ModelProviderRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ export function ModelProviderRow({
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);
Expand Down Expand Up @@ -156,7 +155,6 @@ export function ModelProviderRow({
setEditingKey(null);
setError("");
setShowSavedState(false);
setPreserveSetupLayout(false);

const unlisten = await onModelSetupOutput(provider.id, appendSetupOutput);

Expand All @@ -179,7 +177,6 @@ export function ModelProviderRow({
setExpanded((current) => {
if (current) {
setShowSavedState(false);
setPreserveSetupLayout(false);
}
return !current;
});
Expand Down Expand Up @@ -275,7 +272,6 @@ export function ModelProviderRow({
}
await loadConfig();
setShowSavedState(true);
setPreserveSetupLayout(true);
} catch (nextError) {
setError(
nextError instanceof Error ? nextError.message : "Failed to save",
Expand All @@ -291,7 +287,6 @@ export function ModelProviderRow({
setEditingKey(null);
setError("");
setShowSavedState(false);
setPreserveSetupLayout(false);
} catch (nextError) {
setError(
nextError instanceof Error ? nextError.message : "Failed to remove",
Expand Down Expand Up @@ -378,7 +373,7 @@ export function ModelProviderRow({
);
}

if (hasFields && isConnected && !preserveSetupLayout) {
if (hasFields && isConnected) {
return (
<ConnectedFieldsPanel
panelRef={panelRef}
Expand Down
119 changes: 97 additions & 22 deletions src/features/settings/ui/ProvidersSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, 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 { IconChevronDown } from "@tabler/icons-react";
import {
getAgentProviders,
getModelProviders,
Expand All @@ -20,8 +20,10 @@ import type {
function resolveStatus(
entry: ProviderCatalogEntry,
configuredIds: Set<string>,
hasModelProvider: boolean,
): ProviderSetupStatus {
if (entry.id === "goose") return "built_in";
if (entry.id === "goose")
return hasModelProvider ? "built_in" : "needs_model";
if (entry.category === "agent") return "not_installed";
if (configuredIds.has(entry.id)) return "connected";
return "not_configured";
Expand All @@ -30,18 +32,38 @@ function resolveStatus(
function toDisplayInfo(
entries: ProviderCatalogEntry[],
configuredIds: Set<string>,
hasModelProvider: boolean,
): ProviderDisplayInfo[] {
return entries.map((entry) => ({
...entry,
status: resolveStatus(entry, configuredIds),
status: resolveStatus(entry, configuredIds, hasModelProvider),
}));
}

export function ProvidersSettings() {
interface ProvidersSettingsProps {
scrollContainerRef?: React.RefObject<HTMLElement | null>;
onNeedsRestart?: () => void;
}

export function ProvidersSettings({
scrollContainerRef,
onNeedsRestart,
}: ProvidersSettingsProps) {
const { t } = useTranslation(["settings", "common"]);
const [showAllModels, setShowAllModels] = useState(false);
const [modelOrder, setModelOrder] = useState<string[] | null>(null);

const modelsSectionRef = useRef<HTMLElement>(null);
const scrollRafRef = useRef<number | null>(null);

useEffect(() => {
return () => {
if (scrollRafRef.current !== null) {
cancelAnimationFrame(scrollRafRef.current);
}
};
}, []);

const {
configuredIds,
loading,
Expand All @@ -50,18 +72,75 @@ export function ProvidersSettings() {
getConfig,
save,
remove,
restart,
completeNativeSetup,
} = useCredentials();

useEffect(() => {
if (needsRestart) onNeedsRestart?.();
}, [needsRestart, onNeedsRestart]);

const modelProviderIds = useMemo(
() => new Set(getModelProviders().map((m) => m.id)),
[],
);

const hasModelProvider = useMemo(
() => [...configuredIds].some((id) => modelProviderIds.has(id)),
[configuredIds, modelProviderIds],
);

const scrollToModels = useCallback(() => {
const target = modelsSectionRef.current;
if (!target) return;

if (scrollRafRef.current !== null) {
cancelAnimationFrame(scrollRafRef.current);
scrollRafRef.current = null;
}

const scrollEl = scrollContainerRef?.current;
if (!scrollEl) {
target.scrollIntoView({ behavior: "smooth" });
return;
}

const targetTop =
target.getBoundingClientRect().top -
scrollEl.getBoundingClientRect().top +
scrollEl.scrollTop -
16;
const start = scrollEl.scrollTop;
const distance = targetTop - start;
const duration = 500;
let startTime: number | null = null;

function easeInOut(p: number) {
return p < 0.5 ? 4 * p * p * p : 1 - (-2 * p + 2) ** 3 / 2;
}

const step = (timestamp: number) => {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
const progress = Math.min(elapsed / duration, 1);
scrollEl.scrollTop = start + distance * easeInOut(progress);
if (progress < 1) {
scrollRafRef.current = requestAnimationFrame(step);
} else {
scrollRafRef.current = null;
}
};

scrollRafRef.current = requestAnimationFrame(step);
}, [scrollContainerRef]);

const agents = useMemo(
() => toDisplayInfo(getAgentProviders(), configuredIds),
[configuredIds],
() => toDisplayInfo(getAgentProviders(), configuredIds, hasModelProvider),
[configuredIds, hasModelProvider],
);

const allModels = useMemo(
() => toDisplayInfo(getModelProviders(), configuredIds),
[configuredIds],
() => toDisplayInfo(getModelProviders(), configuredIds, hasModelProvider),
[configuredIds, hasModelProvider],
);

const sortedModels = useMemo(() => {
Expand Down Expand Up @@ -138,16 +217,6 @@ export function ProvidersSettings() {
{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>
Expand All @@ -162,14 +231,20 @@ export function ProvidersSettings() {

<div className="grid grid-cols-2 gap-3">
{agents.map((agent) => (
<AgentProviderCard key={agent.id} provider={agent} />
<AgentProviderCard
key={agent.id}
provider={agent}
onScrollToModels={
agent.id === "goose" ? scrollToModels : undefined
}
/>
))}
</div>
</section>

<Separator className="my-6" />

<section>
<section ref={modelsSectionRef} className="scroll-mt-4">
<div className="mb-3">
<h4 className="text-sm font-semibold">
{t("providers.models.title")}
Expand Down
Loading
Loading