diff --git a/src/features/providers/hooks/useCredentials.ts b/src/features/providers/hooks/useCredentials.ts index 8b7c73b8..739e200f 100644 --- a/src/features/providers/hooks/useCredentials.ts +++ b/src/features/providers/hooks/useCredentials.ts @@ -5,7 +5,6 @@ import { deleteProviderConfig, type ProviderStatus, checkAllProviderStatus, - restartApp, } from "@/features/providers/api/credentials"; import type { ProviderFieldValue } from "@/shared/types/providers"; @@ -17,7 +16,6 @@ interface UseCredentialsReturn { getConfig: (providerId: string) => Promise; save: (key: string, value: string) => Promise; remove: (providerId: string) => Promise; - restart: () => Promise; completeNativeSetup: () => Promise; } @@ -77,10 +75,6 @@ export function useCredentials(): UseCredentialsReturn { [refreshStatuses], ); - const restart = useCallback(async () => { - await restartApp(); - }, []); - const completeNativeSetup = useCallback(async () => { await refreshStatuses(); setNeedsRestart(true); @@ -94,7 +88,6 @@ export function useCredentials(): UseCredentialsReturn { getConfig, save, remove, - restart, completeNativeSetup, }; } diff --git a/src/features/settings/ui/AgentProviderCard.tsx b/src/features/settings/ui/AgentProviderCard.tsx index d4df5e03..866b31f9 100644 --- a/src/features/settings/ui/AgentProviderCard.tsx +++ b/src/features/settings/ui/AgentProviderCard.tsx @@ -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, @@ -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("idle"); const [setupOutput, setSetupOutput] = useState([]); @@ -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; @@ -224,7 +231,7 @@ 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 = @@ -232,6 +239,28 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) { const needsInstall = isInstalled === false && hasInstallCommand; function renderStatusIndicator() { + if (needsModelProvider && onScrollToModels) { + return ( + + + + + + {t("providers.agents.connectModelTooltip")} + + + ); + } + if (isBuiltIn || isReady) { return (
diff --git a/src/features/settings/ui/ModelProviderRow.tsx b/src/features/settings/ui/ModelProviderRow.tsx index 7a6751db..2ecdaa04 100644 --- a/src/features/settings/ui/ModelProviderRow.tsx +++ b/src/features/settings/ui/ModelProviderRow.tsx @@ -64,7 +64,6 @@ export function ModelProviderRow({ const [setupOutput, setSetupOutput] = useState([]); 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); @@ -156,7 +155,6 @@ export function ModelProviderRow({ setEditingKey(null); setError(""); setShowSavedState(false); - setPreserveSetupLayout(false); const unlisten = await onModelSetupOutput(provider.id, appendSetupOutput); @@ -179,7 +177,6 @@ export function ModelProviderRow({ setExpanded((current) => { if (current) { setShowSavedState(false); - setPreserveSetupLayout(false); } return !current; }); @@ -275,7 +272,6 @@ export function ModelProviderRow({ } await loadConfig(); setShowSavedState(true); - setPreserveSetupLayout(true); } catch (nextError) { setError( nextError instanceof Error ? nextError.message : "Failed to save", @@ -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", @@ -378,7 +373,7 @@ export function ModelProviderRow({ ); } - if (hasFields && isConnected && !preserveSetupLayout) { + if (hasFields && isConnected) { return ( , + 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"; @@ -30,18 +32,38 @@ function resolveStatus( function toDisplayInfo( entries: ProviderCatalogEntry[], configuredIds: Set, + 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; + onNeedsRestart?: () => void; +} + +export function ProvidersSettings({ + scrollContainerRef, + onNeedsRestart, +}: ProvidersSettingsProps) { const { t } = useTranslation(["settings", "common"]); const [showAllModels, setShowAllModels] = useState(false); const [modelOrder, setModelOrder] = useState(null); + const modelsSectionRef = useRef(null); + const scrollRafRef = useRef(null); + + useEffect(() => { + return () => { + if (scrollRafRef.current !== null) { + cancelAnimationFrame(scrollRafRef.current); + } + }; + }, []); + const { configuredIds, loading, @@ -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(() => { @@ -138,16 +217,6 @@ export function ProvidersSettings() { {t("providers.description")}

- {needsRestart && ( -
-

{t("providers.restartMessage")}

- -
- )} -
@@ -162,14 +231,20 @@ export function ProvidersSettings() {
{agents.map((agent) => ( - + ))}
-
+

{t("providers.models.title")} diff --git a/src/features/settings/ui/SettingsModal.tsx b/src/features/settings/ui/SettingsModal.tsx index 0a9f447a..f501dc07 100644 --- a/src/features/settings/ui/SettingsModal.tsx +++ b/src/features/settings/ui/SettingsModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; import { cn } from "@/shared/lib/cn"; import { type LocalePreference, useLocale } from "@/shared/i18n"; @@ -78,6 +78,15 @@ export function SettingsModal({ const [deletingProject, setDeletingProject] = useState( null, ); + const contentScrollRef = useRef(null); + const [showRestartCard, setShowRestartCard] = useState(false); + const [restartCardVisible, setRestartCardVisible] = useState(false); + const restartDismissedRef = useRef(false); + + const handleNeedsRestart = useCallback(() => { + if (restartDismissedRef.current) return; + setShowRestartCard(true); + }, []); // Trigger entrance animations after mount useEffect(() => { @@ -124,7 +133,20 @@ export function SettingsModal({ } }; - // Content transition on section change + useEffect(() => { + if (!showRestartCard) return; + const timer = setTimeout(() => setRestartCardVisible(true), 50); + return () => clearTimeout(timer); + }, [showRestartCard]); + + useEffect(() => { + if (activeSection !== "providers" && showRestartCard) { + setRestartCardVisible(false); + setShowRestartCard(false); + restartDismissedRef.current = true; + } + }, [activeSection, showRestartCard]); + // biome-ignore lint/correctness/useExhaustiveDependencies: activeSection triggers the transition effect intentionally useEffect(() => { setIsTransitioning(true); @@ -152,263 +174,292 @@ export function SettingsModal({ {/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation on inner container is not a meaningful interaction */} {/* biome-ignore lint/a11y/noStaticElementInteractions: click handler only prevents backdrop dismiss propagation */}
e.stopPropagation()} > - {/* Sidebar */}
+ {/* Sidebar */}
-

{t("title")}

+
+

{t("title")}

+
+
- -
- - {/* Content */} -
- + {/* Content */}
+ +
- {activeSection === "appearance" && } - {activeSection === "providers" && } - {activeSection === "doctor" && } - {activeSection === "general" && ( -
-
-

- {t("general.title")} -

-

- {t("general.description")} -

-
- -
+
+ {activeSection === "appearance" && } + {activeSection === "providers" && ( + + )} + {activeSection === "doctor" && } + {activeSection === "general" && ( +
-

- {t("general.language.label")} -

-

- {t("general.language.description")} +

+ {t("general.title")} +

+

+ {t("general.description")}

- -
-
- )} - {activeSection === "projects" && ( -
-
-

- {t("projects.title")} -

-

- {t("projects.description")} -

-
- {/* Archived Projects */} -
-

- {t("projects.sectionTitle")} -

- {!loadingArchived && archivedProjects.length === 0 && ( -

- {t("projects.empty")} -

- )} - {archivedProjects.map((project) => ( -
+
+

+ {t("general.language.label")} +

+

+ {t("general.language.description")} +

+
+ +
+
+ )} + {activeSection === "projects" && ( +
+
+

+ {t("projects.title")} +

+

+ {t("projects.description")} +

+
+ + {/* Archived Projects */} +
+

+ {t("projects.sectionTitle")} +

+ {!loadingArchived && archivedProjects.length === 0 && ( +

+ {t("projects.empty")} +

+ )} + {archivedProjects.map((project) => ( +
+
+ + + {project.name} + +
+
+ + +
-
+ ))} +
+
+ )} + {activeSection === "chats" && ( +
+
+

+ {t("chats.title")} +

+

+ {t("chats.description")} +

+
+ +
+

+ {t("chats.sectionTitle")} +

+ {!loadingArchivedChats && archivedChats.length === 0 && ( +

+ {t("chats.empty")} +

+ )} + {archivedChats.map((session) => ( +
+
+
+ {getDisplaySessionTitle( + session.title, + t("common:session.defaultTitle"), + )} +
+

+ {session.projectId + ? t("chats.types.project") + : t("chats.types.standalone")} +

+
-
-
- ))} + ))} +
-
- )} - {activeSection === "chats" && ( -
+ )} + {activeSection === "about" && (

- {t("chats.title")} + {t("about.title")}

- {t("chats.description")} + {t("about.description")}

- -
-

- {t("chats.sectionTitle")} -

- {!loadingArchivedChats && archivedChats.length === 0 && ( -

- {t("chats.empty")} -

- )} - {archivedChats.map((session) => ( -
-
-
- {getDisplaySessionTitle( - session.title, - t("common:session.defaultTitle"), - )} -
-

- {session.projectId - ? t("chats.types.project") - : t("chats.types.standalone")} -

-
- -
- ))} -
-
- )} - {activeSection === "about" && ( -
-

- {t("about.title")} -

-

- {t("about.description")} -

-
- )} + )} +
+ + {showRestartCard && ( +
+

+ {t("providers.restartMessage")} +

+
+ )}
) { +}: React.ComponentProps & { + showArrow?: boolean; +}) { return ( {children} - + {showArrow && ( + + )} );