From 5fae39f629c32dd23fc8a7161567d4aee125e8a0 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:56:19 -0700 Subject: [PATCH 1/6] add needs_model status for Goose agent when no model provider is connected Goose agent now shows a "needs_model" state with a scroll-to-models button when no model provider is configured. Updates provider copy to better explain the relationship between agents and model providers. --- .../settings/ui/AgentProviderCard.tsx | 27 ++++++- .../settings/ui/ProvidersSettings.tsx | 74 ++++++++++++++++--- src/shared/i18n/locales/en/settings.json | 11 +-- src/shared/types/providers.ts | 1 + 4 files changed, 96 insertions(+), 17 deletions(-) diff --git a/src/features/settings/ui/AgentProviderCard.tsx b/src/features/settings/ui/AgentProviderCard.tsx index d4df5e03..7edc3050 100644 --- a/src/features/settings/ui/AgentProviderCard.tsx +++ b/src/features/settings/ui/AgentProviderCard.tsx @@ -25,9 +25,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 +45,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 +230,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 +238,21 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) { const needsInstall = isInstalled === false && hasInstallCommand; function renderStatusIndicator() { + if (needsModelProvider) { + return ( + + ); + } + if (isBuiltIn || isReady) { return (
diff --git a/src/features/settings/ui/ProvidersSettings.tsx b/src/features/settings/ui/ProvidersSettings.tsx index 38d34ca5..d974a114 100644 --- a/src/features/settings/ui/ProvidersSettings.tsx +++ b/src/features/settings/ui/ProvidersSettings.tsx @@ -1,4 +1,4 @@ -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"; @@ -20,8 +20,10 @@ import type { function resolveStatus( entry: ProviderCatalogEntry, configuredIds: Set, + 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,10 +32,11 @@ 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), })); } @@ -42,6 +45,8 @@ export function ProvidersSettings() { const [showAllModels, setShowAllModels] = useState(false); const [modelOrder, setModelOrder] = useState(null); + const modelsSectionRef = useRef(null); + const { configuredIds, loading, @@ -54,14 +59,59 @@ export function ProvidersSettings() { completeNativeSetup, } = useCredentials(); + 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; + + const container = target.closest("[class*='overflow-y']"); + if (!container) { + target.scrollIntoView({ behavior: "smooth" }); + return; + } + + const targetTop = + target.getBoundingClientRect().top - + container.getBoundingClientRect().top + + container.scrollTop - + 16; + const start = container.scrollTop; + const distance = targetTop - start; + const duration = 500; + let startTime: number | null = null; + + function easeInOut(t: number) { + return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2; + } + + function step(timestamp: number) { + if (!startTime) startTime = timestamp; + const elapsed = timestamp - startTime; + const progress = Math.min(elapsed / duration, 1); + container.scrollTop = start + distance * easeInOut(progress); + if (progress < 1) requestAnimationFrame(step); + } + + requestAnimationFrame(step); + }, []); + 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(() => { @@ -162,14 +212,20 @@ export function ProvidersSettings() {
{agents.map((agent) => ( - + ))}
-
+

{t("providers.models.title")} diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index 85a66483..d53d9444 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -95,16 +95,17 @@ }, "providers": { "agents": { - "description": "Agents handle your requests using their own tools and models", + "connectModelLabel": "Connect a model provider for Goose", + "description": "Each agent handles your requests differently. External agents bring their own models. Goose uses the model providers below.", "installLabel": "Install {{name}}", "signIn": "Sign in", "signInLabel": "Sign in to {{name}}", - "title": "Agents" + "title": "Agent Providers" }, - "description": "Connect agents and AI models to use with Goose", + "description": "Connect an agent to get started. Goose uses the model providers below; other agents bring their own.", "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.", + "description": "Connect a model provider to power the Goose agent. You need at least one connected to use Goose.", "notSet": "Not set", "setup": { "connected": { @@ -128,7 +129,7 @@ "oauthTerminal": "Run `goose configure` in your terminal to finish sign-in." } }, - "title": "Models" + "title": "Model Providers" }, "restartButton": "Restart now", "restartMessage": "Restart to apply credential changes.", diff --git a/src/shared/types/providers.ts b/src/shared/types/providers.ts index 1c11da26..2e7ef5a5 100644 --- a/src/shared/types/providers.ts +++ b/src/shared/types/providers.ts @@ -50,6 +50,7 @@ export interface ProviderCatalogEntry { export type ProviderSetupStatus = | "built_in" | "connected" + | "needs_model" | "not_installed" | "not_configured" | "installing" From d09de04be1efbff568761a3bac5488e7c63f2c9c Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:39:15 -0700 Subject: [PATCH 2/6] polish providers settings: floating restart card, tooltip arrow prop, save-state fix Move the restart banner out of ProvidersSettings into a floating card below the settings modal (Figma-style stacked layout). The card fades in on credential changes, dismisses when navigating away, and won't reappear on return. Add showArrow prop to TooltipContent for arrow-free tooltips. Fix model provider save flow showing empty fields by removing the preserveSetupLayout flag that blocked transition to ConnectedFieldsPanel. --- .../settings/ui/AgentProviderCard.tsx | 32 +++++++--- src/features/settings/ui/ModelProviderRow.tsx | 7 +-- .../settings/ui/ProvidersSettings.tsx | 44 ++++++++----- src/features/settings/ui/SettingsModal.tsx | 63 +++++++++++++++++-- src/shared/i18n/locales/en/settings.json | 5 +- src/shared/ui/tooltip.tsx | 9 ++- 6 files changed, 119 insertions(+), 41 deletions(-) diff --git a/src/features/settings/ui/AgentProviderCard.tsx b/src/features/settings/ui/AgentProviderCard.tsx index 7edc3050..5fb6c4aa 100644 --- a/src/features/settings/ui/AgentProviderCard.tsx +++ b/src/features/settings/ui/AgentProviderCard.tsx @@ -5,6 +5,11 @@ 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, @@ -240,16 +245,23 @@ export function AgentProviderCard({ function renderStatusIndicator() { if (needsModelProvider) { return ( - + + + + + + {t("providers.agents.connectModelTooltip")} + + ); } 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 ( Promise) => void; +} + +export function ProvidersSettings({ 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); const { configuredIds, @@ -59,6 +64,10 @@ export function ProvidersSettings() { completeNativeSetup, } = useCredentials(); + useEffect(() => { + if (needsRestart) onNeedsRestart?.(restart); + }, [needsRestart, onNeedsRestart, restart]); + const modelProviderIds = useMemo( () => new Set(getModelProviders().map((m) => m.id)), [], @@ -73,7 +82,14 @@ export function ProvidersSettings() { const target = modelsSectionRef.current; if (!target) return; - const container = target.closest("[class*='overflow-y']"); + if (scrollRafRef.current !== null) { + cancelAnimationFrame(scrollRafRef.current); + scrollRafRef.current = null; + } + + const container = target.closest( + "[class*='overflow-y-auto'], [class*='overflow-y-scroll']", + ); if (!container) { target.scrollIntoView({ behavior: "smooth" }); return; @@ -89,8 +105,8 @@ export function ProvidersSettings() { const duration = 500; let startTime: number | null = null; - function easeInOut(t: number) { - return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2; + function easeInOut(p: number) { + return p < 0.5 ? 4 * p * p * p : 1 - (-2 * p + 2) ** 3 / 2; } function step(timestamp: number) { @@ -98,10 +114,14 @@ export function ProvidersSettings() { const elapsed = timestamp - startTime; const progress = Math.min(elapsed / duration, 1); container.scrollTop = start + distance * easeInOut(progress); - if (progress < 1) requestAnimationFrame(step); + if (progress < 1) { + scrollRafRef.current = requestAnimationFrame(step); + } else { + scrollRafRef.current = null; + } } - requestAnimationFrame(step); + scrollRafRef.current = requestAnimationFrame(step); }, []); const agents = useMemo( @@ -188,16 +208,6 @@ export function ProvidersSettings() { {t("providers.description")}

- {needsRestart && ( -
-

{t("providers.restartMessage")}

- -
- )} -
diff --git a/src/features/settings/ui/SettingsModal.tsx b/src/features/settings/ui/SettingsModal.tsx index 0a9f447a..dcd32f96 100644 --- a/src/features/settings/ui/SettingsModal.tsx +++ b/src/features/settings/ui/SettingsModal.tsx @@ -1,8 +1,9 @@ -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"; import { Button, buttonVariants } from "@/shared/ui/button"; +import { IconRefresh } from "@tabler/icons-react"; import { AlertDialog, AlertDialogAction, @@ -78,6 +79,16 @@ export function SettingsModal({ const [deletingProject, setDeletingProject] = useState( null, ); + const [restartFn, setRestartFn] = useState<(() => Promise) | null>( + null, + ); + const [restartVisible, setRestartVisible] = useState(false); + const restartDismissedRef = useRef(false); + + const handleNeedsRestart = useCallback((restart: () => Promise) => { + if (restartDismissedRef.current) return; + setRestartFn(() => restart); + }, []); // Trigger entrance animations after mount useEffect(() => { @@ -124,6 +135,20 @@ export function SettingsModal({ } }; + useEffect(() => { + if (!restartFn) return; + const timer = setTimeout(() => setRestartVisible(true), 50); + return () => clearTimeout(timer); + }, [restartFn]); + + useEffect(() => { + if (activeSection !== "providers" && restartFn) { + setRestartVisible(false); + setRestartFn(null); + restartDismissedRef.current = true; + } + }, [activeSection, restartFn]); + // Content transition on section change // biome-ignore lint/correctness/useExhaustiveDependencies: activeSection triggers the transition effect intentionally useEffect(() => { @@ -151,13 +176,16 @@ 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()} + >
e.stopPropagation()} > {/* Sidebar */}
{activeSection === "appearance" && } - {activeSection === "providers" && } + {activeSection === "providers" && ( + + )} {activeSection === "doctor" && } {activeSection === "general" && (
@@ -411,6 +441,31 @@ export function SettingsModal({
+ {restartFn && ( +
+

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

+ +
+ )} +
+ !open && setDeletingProject(null)} diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index d53d9444..81451239 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -96,6 +96,7 @@ "providers": { "agents": { "connectModelLabel": "Connect a model provider for Goose", + "connectModelTooltip": "Connect a model provider", "description": "Each agent handles your requests differently. External agents bring their own models. Goose uses the model providers below.", "installLabel": "Install {{name}}", "signIn": "Sign in", @@ -131,8 +132,8 @@ }, "title": "Model Providers" }, - "restartButton": "Restart now", - "restartMessage": "Restart to apply credential changes.", + "restartButton": "Restart", + "restartMessage": "You may need to restart for changes to take effect.", "saved": "Saved", "showFewer": "Show fewer", "showMore": "Show {{count}} more providers", diff --git a/src/shared/ui/tooltip.tsx b/src/shared/ui/tooltip.tsx index c85599d6..e92fe347 100644 --- a/src/shared/ui/tooltip.tsx +++ b/src/shared/ui/tooltip.tsx @@ -35,9 +35,12 @@ function TooltipTrigger({ function TooltipContent({ className, sideOffset = 0, + showArrow = true, children, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + showArrow?: boolean; +}) { return ( {children} - + {showArrow && ( + + )} ); From 0b1a74fcc701c2d02c7d0b9d1e212bd4b29fad2e Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:52:29 -0700 Subject: [PATCH 3/6] harden providers settings: remove restart banner, fix scroll cleanup and container ref Remove the restart banner since users can restart the app themselves. Fix RAF leak on unmount, replace fragile CSS class selector with an explicit scroll container ref, and guard the needs_model tooltip button on having a handler. --- .../settings/ui/AgentProviderCard.tsx | 2 +- .../settings/ui/ProvidersSettings.tsx | 26 ++++---- src/features/settings/ui/SettingsModal.tsx | 66 +++---------------- src/shared/i18n/locales/en/settings.json | 2 - src/shared/i18n/locales/es/settings.json | 2 - 5 files changed, 23 insertions(+), 75 deletions(-) diff --git a/src/features/settings/ui/AgentProviderCard.tsx b/src/features/settings/ui/AgentProviderCard.tsx index 5fb6c4aa..7f6b3221 100644 --- a/src/features/settings/ui/AgentProviderCard.tsx +++ b/src/features/settings/ui/AgentProviderCard.tsx @@ -243,7 +243,7 @@ export function AgentProviderCard({ const needsInstall = isInstalled === false && hasInstallCommand; function renderStatusIndicator() { - if (needsModelProvider) { + if (needsModelProvider && onScrollToModels) { return ( diff --git a/src/features/settings/ui/ProvidersSettings.tsx b/src/features/settings/ui/ProvidersSettings.tsx index 7f5ec154..a0bb8486 100644 --- a/src/features/settings/ui/ProvidersSettings.tsx +++ b/src/features/settings/ui/ProvidersSettings.tsx @@ -41,10 +41,12 @@ function toDisplayInfo( } interface ProvidersSettingsProps { - onNeedsRestart?: (restart: () => Promise) => void; + scrollContainerRef?: React.RefObject; } -export function ProvidersSettings({ onNeedsRestart }: ProvidersSettingsProps) { +export function ProvidersSettings({ + scrollContainerRef, +}: ProvidersSettingsProps) { const { t } = useTranslation(["settings", "common"]); const [showAllModels, setShowAllModels] = useState(false); const [modelOrder, setModelOrder] = useState(null); @@ -52,22 +54,24 @@ export function ProvidersSettings({ onNeedsRestart }: ProvidersSettingsProps) { const modelsSectionRef = useRef(null); const scrollRafRef = useRef(null); + useEffect(() => { + return () => { + if (scrollRafRef.current !== null) { + cancelAnimationFrame(scrollRafRef.current); + } + }; + }, []); + const { configuredIds, loading, saving, - needsRestart, getConfig, save, remove, - restart, completeNativeSetup, } = useCredentials(); - useEffect(() => { - if (needsRestart) onNeedsRestart?.(restart); - }, [needsRestart, onNeedsRestart, restart]); - const modelProviderIds = useMemo( () => new Set(getModelProviders().map((m) => m.id)), [], @@ -87,9 +91,7 @@ export function ProvidersSettings({ onNeedsRestart }: ProvidersSettingsProps) { scrollRafRef.current = null; } - const container = target.closest( - "[class*='overflow-y-auto'], [class*='overflow-y-scroll']", - ); + const container = scrollContainerRef?.current; if (!container) { target.scrollIntoView({ behavior: "smooth" }); return; @@ -122,7 +124,7 @@ export function ProvidersSettings({ onNeedsRestart }: ProvidersSettingsProps) { } scrollRafRef.current = requestAnimationFrame(step); - }, []); + }, [scrollContainerRef]); const agents = useMemo( () => toDisplayInfo(getAgentProviders(), configuredIds, hasModelProvider), diff --git a/src/features/settings/ui/SettingsModal.tsx b/src/features/settings/ui/SettingsModal.tsx index dcd32f96..a25c0337 100644 --- a/src/features/settings/ui/SettingsModal.tsx +++ b/src/features/settings/ui/SettingsModal.tsx @@ -1,9 +1,8 @@ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { cn } from "@/shared/lib/cn"; import { type LocalePreference, useLocale } from "@/shared/i18n"; import { Button, buttonVariants } from "@/shared/ui/button"; -import { IconRefresh } from "@tabler/icons-react"; import { AlertDialog, AlertDialogAction, @@ -79,16 +78,7 @@ export function SettingsModal({ const [deletingProject, setDeletingProject] = useState( null, ); - const [restartFn, setRestartFn] = useState<(() => Promise) | null>( - null, - ); - const [restartVisible, setRestartVisible] = useState(false); - const restartDismissedRef = useRef(false); - - const handleNeedsRestart = useCallback((restart: () => Promise) => { - if (restartDismissedRef.current) return; - setRestartFn(() => restart); - }, []); + const contentScrollRef = useRef(null); // Trigger entrance animations after mount useEffect(() => { @@ -135,20 +125,6 @@ export function SettingsModal({ } }; - useEffect(() => { - if (!restartFn) return; - const timer = setTimeout(() => setRestartVisible(true), 50); - return () => clearTimeout(timer); - }, [restartFn]); - - useEffect(() => { - if (activeSection !== "providers" && restartFn) { - setRestartVisible(false); - setRestartFn(null); - restartDismissedRef.current = true; - } - }, [activeSection, restartFn]); - // Content transition on section change // biome-ignore lint/correctness/useExhaustiveDependencies: activeSection triggers the transition effect intentionally useEffect(() => { @@ -176,16 +152,13 @@ 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()} - >
e.stopPropagation()} > {/* Sidebar */}
{/* Content */} -
+
- {restartFn && ( -
-

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

- -
- )} -
- !open && setDeletingProject(null)} diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index 81451239..096c3d23 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -132,8 +132,6 @@ }, "title": "Model Providers" }, - "restartButton": "Restart", - "restartMessage": "You may need to restart for changes to take effect.", "saved": "Saved", "showFewer": "Show fewer", "showMore": "Show {{count}} more providers", diff --git a/src/shared/i18n/locales/es/settings.json b/src/shared/i18n/locales/es/settings.json index 8d325d6b..3f7a2cb3 100644 --- a/src/shared/i18n/locales/es/settings.json +++ b/src/shared/i18n/locales/es/settings.json @@ -130,8 +130,6 @@ }, "title": "Modelos" }, - "restartButton": "Reiniciar ahora", - "restartMessage": "Reinicia para aplicar los cambios de credenciales.", "saved": "Guardado", "showFewer": "Mostrar menos", "showMore": "Mostrar {{count}} proveedores más", From 7f31faf9a629f82e94bb716b7909830ae0ff63a8 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:14:17 -0700 Subject: [PATCH 4/6] refactor useCredentials and ProvidersSettings: remove unused restart logic and improve scroll handling Eliminate the restart logic from the useCredentials hook, as it is no longer necessary. Update ProvidersSettings to enhance scroll handling by replacing a fragile container reference with a more reliable approach. Additionally, clean up the code in AgentProviderCard and SettingsModal for better readability. --- src/features/providers/hooks/useCredentials.ts | 13 ------------- src/features/settings/ui/AgentProviderCard.tsx | 6 +----- src/features/settings/ui/ProvidersSettings.tsx | 16 ++++++++-------- src/features/settings/ui/SettingsModal.tsx | 4 +--- src/shared/i18n/locales/es/settings.json | 12 +++++++----- 5 files changed, 17 insertions(+), 34 deletions(-) diff --git a/src/features/providers/hooks/useCredentials.ts b/src/features/providers/hooks/useCredentials.ts index 8b7c73b8..98767d4c 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"; @@ -13,11 +12,9 @@ interface UseCredentialsReturn { configuredIds: Set; loading: boolean; saving: boolean; - needsRestart: boolean; getConfig: (providerId: string) => Promise; save: (key: string, value: string) => Promise; remove: (providerId: string) => Promise; - restart: () => Promise; completeNativeSetup: () => Promise; } @@ -25,7 +22,6 @@ export function useCredentials(): UseCredentialsReturn { const [statuses, setStatuses] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [needsRestart, setNeedsRestart] = useState(false); const refreshStatuses = useCallback(async () => { const nextStatuses = await checkAllProviderStatus(); @@ -55,7 +51,6 @@ export function useCredentials(): UseCredentialsReturn { try { await saveProviderField(key, value); await refreshStatuses(); - setNeedsRestart(true); } finally { setSaving(false); } @@ -69,7 +64,6 @@ export function useCredentials(): UseCredentialsReturn { try { await deleteProviderConfig(providerId); await refreshStatuses(); - setNeedsRestart(true); } finally { setSaving(false); } @@ -77,24 +71,17 @@ export function useCredentials(): UseCredentialsReturn { [refreshStatuses], ); - const restart = useCallback(async () => { - await restartApp(); - }, []); - const completeNativeSetup = useCallback(async () => { await refreshStatuses(); - setNeedsRestart(true); }, [refreshStatuses]); return { configuredIds, loading, saving, - needsRestart, getConfig, save, remove, - restart, completeNativeSetup, }; } diff --git a/src/features/settings/ui/AgentProviderCard.tsx b/src/features/settings/ui/AgentProviderCard.tsx index 7f6b3221..866b31f9 100644 --- a/src/features/settings/ui/AgentProviderCard.tsx +++ b/src/features/settings/ui/AgentProviderCard.tsx @@ -5,11 +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 { Tooltip, TooltipTrigger, TooltipContent } from "@/shared/ui/tooltip"; import { checkAgentInstalled, checkAgentAuth, diff --git a/src/features/settings/ui/ProvidersSettings.tsx b/src/features/settings/ui/ProvidersSettings.tsx index a0bb8486..1f2a57e2 100644 --- a/src/features/settings/ui/ProvidersSettings.tsx +++ b/src/features/settings/ui/ProvidersSettings.tsx @@ -91,18 +91,18 @@ export function ProvidersSettings({ scrollRafRef.current = null; } - const container = scrollContainerRef?.current; - if (!container) { + const scrollEl = scrollContainerRef?.current; + if (!scrollEl) { target.scrollIntoView({ behavior: "smooth" }); return; } const targetTop = target.getBoundingClientRect().top - - container.getBoundingClientRect().top + - container.scrollTop - + scrollEl.getBoundingClientRect().top + + scrollEl.scrollTop - 16; - const start = container.scrollTop; + const start = scrollEl.scrollTop; const distance = targetTop - start; const duration = 500; let startTime: number | null = null; @@ -111,17 +111,17 @@ export function ProvidersSettings({ return p < 0.5 ? 4 * p * p * p : 1 - (-2 * p + 2) ** 3 / 2; } - function step(timestamp: number) { + const step = (timestamp: number) => { if (!startTime) startTime = timestamp; const elapsed = timestamp - startTime; const progress = Math.min(elapsed / duration, 1); - container.scrollTop = start + distance * easeInOut(progress); + scrollEl.scrollTop = start + distance * easeInOut(progress); if (progress < 1) { scrollRafRef.current = requestAnimationFrame(step); } else { scrollRafRef.current = null; } - } + }; scrollRafRef.current = requestAnimationFrame(step); }, [scrollContainerRef]); diff --git a/src/features/settings/ui/SettingsModal.tsx b/src/features/settings/ui/SettingsModal.tsx index a25c0337..67a7c35d 100644 --- a/src/features/settings/ui/SettingsModal.tsx +++ b/src/features/settings/ui/SettingsModal.tsx @@ -239,9 +239,7 @@ export function SettingsModal({ > {activeSection === "appearance" && } {activeSection === "providers" && ( - + )} {activeSection === "doctor" && } {activeSection === "general" && ( diff --git a/src/shared/i18n/locales/es/settings.json b/src/shared/i18n/locales/es/settings.json index 3f7a2cb3..a70849fe 100644 --- a/src/shared/i18n/locales/es/settings.json +++ b/src/shared/i18n/locales/es/settings.json @@ -95,16 +95,18 @@ }, "providers": { "agents": { - "description": "Los agentes manejan tus solicitudes usando sus propias herramientas y modelos", + "connectModelLabel": "Conectar un proveedor de modelos para Goose", + "connectModelTooltip": "Conectar un proveedor de modelos", + "description": "Cada agente maneja tus solicitudes de manera diferente. Los agentes externos traen sus propios modelos. Goose usa los proveedores de modelos de abajo.", "installLabel": "Instalar {{name}}", "signIn": "Iniciar sesión", "signInLabel": "Iniciar sesión en {{name}}", - "title": "Agentes" + "title": "Proveedores de agentes" }, - "description": "Conecta agentes y modelos de IA para usar con Goose", + "description": "Conecta un agente para comenzar. Goose usa los proveedores de modelos de abajo; otros agentes traen los suyos.", "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.", + "description": "Conecta un proveedor de modelos para alimentar al agente Goose. Necesitas al menos uno conectado para usar Goose.", "notSet": "No configurado", "setup": { "connected": { @@ -128,7 +130,7 @@ "oauthTerminal": "Ejecuta `goose configure` en tu terminal para terminar de iniciar sesión." } }, - "title": "Modelos" + "title": "Proveedores de modelos" }, "saved": "Guardado", "showFewer": "Mostrar menos", From 0268f938b7ee25da240bf7cc4d9de09d563fd7a8 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:00:09 -0700 Subject: [PATCH 5/6] restore floating restart card, treat local providers as available Bring back the floating restart notification card below the settings modal (message only, no restart button). The card fades in on credential changes and auto-dismisses when leaving the providers section. Treat setupMethod: "local" providers (e.g. Ollama) as always available when computing hasModelProvider, matching the Goose backend's check_provider_configured behavior. This prevents local-only users from seeing a perpetual needs_model indicator on the Goose agent card. --- .../providers/hooks/useCredentials.ts | 6 + .../settings/ui/ProvidersSettings.tsx | 18 +- src/features/settings/ui/SettingsModal.tsx | 480 ++++++++++-------- src/shared/i18n/locales/en/settings.json | 1 + src/shared/i18n/locales/es/settings.json | 1 + 5 files changed, 288 insertions(+), 218 deletions(-) diff --git a/src/features/providers/hooks/useCredentials.ts b/src/features/providers/hooks/useCredentials.ts index 98767d4c..739e200f 100644 --- a/src/features/providers/hooks/useCredentials.ts +++ b/src/features/providers/hooks/useCredentials.ts @@ -12,6 +12,7 @@ interface UseCredentialsReturn { configuredIds: Set; loading: boolean; saving: boolean; + needsRestart: boolean; getConfig: (providerId: string) => Promise; save: (key: string, value: string) => Promise; remove: (providerId: string) => Promise; @@ -22,6 +23,7 @@ export function useCredentials(): UseCredentialsReturn { const [statuses, setStatuses] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [needsRestart, setNeedsRestart] = useState(false); const refreshStatuses = useCallback(async () => { const nextStatuses = await checkAllProviderStatus(); @@ -51,6 +53,7 @@ export function useCredentials(): UseCredentialsReturn { try { await saveProviderField(key, value); await refreshStatuses(); + setNeedsRestart(true); } finally { setSaving(false); } @@ -64,6 +67,7 @@ export function useCredentials(): UseCredentialsReturn { try { await deleteProviderConfig(providerId); await refreshStatuses(); + setNeedsRestart(true); } finally { setSaving(false); } @@ -73,12 +77,14 @@ export function useCredentials(): UseCredentialsReturn { const completeNativeSetup = useCallback(async () => { await refreshStatuses(); + setNeedsRestart(true); }, [refreshStatuses]); return { configuredIds, loading, saving, + needsRestart, getConfig, save, remove, diff --git a/src/features/settings/ui/ProvidersSettings.tsx b/src/features/settings/ui/ProvidersSettings.tsx index 1f2a57e2..5d6a5038 100644 --- a/src/features/settings/ui/ProvidersSettings.tsx +++ b/src/features/settings/ui/ProvidersSettings.tsx @@ -42,10 +42,12 @@ function toDisplayInfo( interface ProvidersSettingsProps { scrollContainerRef?: React.RefObject; + onNeedsRestart?: () => void; } export function ProvidersSettings({ scrollContainerRef, + onNeedsRestart, }: ProvidersSettingsProps) { const { t } = useTranslation(["settings", "common"]); const [showAllModels, setShowAllModels] = useState(false); @@ -66,20 +68,32 @@ export function ProvidersSettings({ configuredIds, loading, saving, + needsRestart, getConfig, save, remove, completeNativeSetup, } = useCredentials(); + useEffect(() => { + if (needsRestart) onNeedsRestart?.(); + }, [needsRestart, onNeedsRestart]); + const modelProviderIds = useMemo( () => new Set(getModelProviders().map((m) => m.id)), [], ); + const hasLocalProvider = useMemo( + () => getModelProviders().some((m) => m.setupMethod === "local"), + [], + ); + const hasModelProvider = useMemo( - () => [...configuredIds].some((id) => modelProviderIds.has(id)), - [configuredIds, modelProviderIds], + () => + hasLocalProvider || + [...configuredIds].some((id) => modelProviderIds.has(id)), + [configuredIds, hasLocalProvider, modelProviderIds], ); const scrollToModels = useCallback(() => { diff --git a/src/features/settings/ui/SettingsModal.tsx b/src/features/settings/ui/SettingsModal.tsx index 67a7c35d..f501dc07 100644 --- a/src/features/settings/ui/SettingsModal.tsx +++ b/src/features/settings/ui/SettingsModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } 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"; @@ -79,6 +79,14 @@ export function SettingsModal({ 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(() => { @@ -125,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); @@ -153,265 +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")} +

+
+ )}
Date: Mon, 13 Apr 2026 21:06:03 -0700 Subject: [PATCH 6/6] revert local provider override for hasModelProvider Only check configuredIds for model provider presence. Local providers (Ollama, local_inference) will be picked up after the Goose backend merge brings check_provider_configured which marks them as configured. --- src/features/settings/ui/ProvidersSettings.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/features/settings/ui/ProvidersSettings.tsx b/src/features/settings/ui/ProvidersSettings.tsx index 5d6a5038..0a3d66b7 100644 --- a/src/features/settings/ui/ProvidersSettings.tsx +++ b/src/features/settings/ui/ProvidersSettings.tsx @@ -84,16 +84,9 @@ export function ProvidersSettings({ [], ); - const hasLocalProvider = useMemo( - () => getModelProviders().some((m) => m.setupMethod === "local"), - [], - ); - const hasModelProvider = useMemo( - () => - hasLocalProvider || - [...configuredIds].some((id) => modelProviderIds.has(id)), - [configuredIds, hasLocalProvider, modelProviderIds], + () => [...configuredIds].some((id) => modelProviderIds.has(id)), + [configuredIds, modelProviderIds], ); const scrollToModels = useCallback(() => {