Skip to content
This repository was archived by the owner on Apr 14, 2026. It is now read-only.

Commit 4e4be4a

Browse files
authored
guide users to connect a model provider when Goose needs one (#169)
* 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. * 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. * 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. * 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. * 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. * 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.
1 parent da6b22b commit 4e4be4a

9 files changed

Lines changed: 417 additions & 266 deletions

File tree

src/features/providers/hooks/useCredentials.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
deleteProviderConfig,
66
type ProviderStatus,
77
checkAllProviderStatus,
8-
restartApp,
98
} from "@/features/providers/api/credentials";
109
import type { ProviderFieldValue } from "@/shared/types/providers";
1110

@@ -17,7 +16,6 @@ interface UseCredentialsReturn {
1716
getConfig: (providerId: string) => Promise<ProviderFieldValue[]>;
1817
save: (key: string, value: string) => Promise<void>;
1918
remove: (providerId: string) => Promise<void>;
20-
restart: () => Promise<void>;
2119
completeNativeSetup: () => Promise<void>;
2220
}
2321

@@ -77,10 +75,6 @@ export function useCredentials(): UseCredentialsReturn {
7775
[refreshStatuses],
7876
);
7977

80-
const restart = useCallback(async () => {
81-
await restartApp();
82-
}, []);
83-
8478
const completeNativeSetup = useCallback(async () => {
8579
await refreshStatuses();
8680
setNeedsRestart(true);
@@ -94,7 +88,6 @@ export function useCredentials(): UseCredentialsReturn {
9488
getConfig,
9589
save,
9690
remove,
97-
restart,
9891
completeNativeSetup,
9992
};
10093
}

src/features/settings/ui/AgentProviderCard.tsx

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Button } from "@/shared/ui/button";
55
import { Spinner } from "@/shared/ui/spinner";
66
import { getProviderIcon } from "@/shared/ui/icons/ProviderIcons";
77
import { IconCheck, IconAlertTriangle, IconPlus } from "@tabler/icons-react";
8+
import { Tooltip, TooltipTrigger, TooltipContent } from "@/shared/ui/tooltip";
89
import {
910
checkAgentInstalled,
1011
checkAgentAuth,
@@ -25,9 +26,13 @@ const MAX_OUTPUT_LINES = 50;
2526

2627
interface AgentProviderCardProps {
2728
provider: ProviderDisplayInfo;
29+
onScrollToModels?: () => void;
2830
}
2931

30-
export function AgentProviderCard({ provider }: AgentProviderCardProps) {
32+
export function AgentProviderCard({
33+
provider,
34+
onScrollToModels,
35+
}: AgentProviderCardProps) {
3136
const { t } = useTranslation(["settings", "common"]);
3237
const [setupPhase, setSetupPhase] = useState<SetupPhase>("idle");
3338
const [setupOutput, setSetupOutput] = useState<OutputLine[]>([]);
@@ -41,7 +46,9 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
4146
const unlistenRef = useRef<(() => void) | null>(null);
4247

4348
const icon = getProviderIcon(provider.id, "size-6");
44-
const isBuiltIn = provider.status === "built_in";
49+
const isBuiltIn =
50+
provider.status === "built_in" || provider.status === "needs_model";
51+
const needsModelProvider = provider.status === "needs_model";
4552
const isActive = setupPhase !== "idle";
4653
const hasInstallCommand = !!provider.installCommand;
4754
const hasAuthCommand = !!provider.authCommand;
@@ -224,14 +231,36 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
224231
if (provider.showOnlyWhenInstalled && isInstalled !== true) return null;
225232

226233
const isReady =
227-
isBuiltIn ||
234+
(isBuiltIn && !needsModelProvider) ||
228235
(isInstalled === true && !hasAuthCommand) ||
229236
(isInstalled === true && isAuthenticated === true);
230237
const needsAuth =
231238
isInstalled === true && hasAuthCommand && isAuthenticated !== true;
232239
const needsInstall = isInstalled === false && hasInstallCommand;
233240

234241
function renderStatusIndicator() {
242+
if (needsModelProvider && onScrollToModels) {
243+
return (
244+
<Tooltip>
245+
<TooltipTrigger asChild>
246+
<Button
247+
type="button"
248+
variant="ghost"
249+
size="icon-xs"
250+
onClick={onScrollToModels}
251+
className="flex-shrink-0 text-muted-foreground"
252+
aria-label={t("providers.agents.connectModelLabel")}
253+
>
254+
<IconPlus className="size-4" />
255+
</Button>
256+
</TooltipTrigger>
257+
<TooltipContent side="left" showArrow={false}>
258+
{t("providers.agents.connectModelTooltip")}
259+
</TooltipContent>
260+
</Tooltip>
261+
);
262+
}
263+
235264
if (isBuiltIn || isReady) {
236265
return (
237266
<div className="flex h-6 flex-shrink-0 items-center">

src/features/settings/ui/ModelProviderRow.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@ export function ModelProviderRow({
6464
const [setupOutput, setSetupOutput] = useState<SetupOutputLine[]>([]);
6565
const [setupError, setSetupError] = useState("");
6666
const [showSavedState, setShowSavedState] = useState(false);
67-
const [preserveSetupLayout, setPreserveSetupLayout] = useState(false);
6867
const setupLineCounter = useRef(0);
6968
const hasLoadedConfig = useRef(false);
7069
const shouldRestorePanelFocus = useRef(false);
@@ -156,7 +155,6 @@ export function ModelProviderRow({
156155
setEditingKey(null);
157156
setError("");
158157
setShowSavedState(false);
159-
setPreserveSetupLayout(false);
160158

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

@@ -179,7 +177,6 @@ export function ModelProviderRow({
179177
setExpanded((current) => {
180178
if (current) {
181179
setShowSavedState(false);
182-
setPreserveSetupLayout(false);
183180
}
184181
return !current;
185182
});
@@ -275,7 +272,6 @@ export function ModelProviderRow({
275272
}
276273
await loadConfig();
277274
setShowSavedState(true);
278-
setPreserveSetupLayout(true);
279275
} catch (nextError) {
280276
setError(
281277
nextError instanceof Error ? nextError.message : "Failed to save",
@@ -291,7 +287,6 @@ export function ModelProviderRow({
291287
setEditingKey(null);
292288
setError("");
293289
setShowSavedState(false);
294-
setPreserveSetupLayout(false);
295290
} catch (nextError) {
296291
setError(
297292
nextError instanceof Error ? nextError.message : "Failed to remove",
@@ -378,7 +373,7 @@ export function ModelProviderRow({
378373
);
379374
}
380375

381-
if (hasFields && isConnected && !preserveSetupLayout) {
376+
if (hasFields && isConnected) {
382377
return (
383378
<ConnectedFieldsPanel
384379
panelRef={panelRef}

src/features/settings/ui/ProvidersSettings.tsx

Lines changed: 97 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { useEffect, useMemo, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
import { useTranslation } from "react-i18next";
33
import { Button } from "@/shared/ui/button";
44
import { Separator } from "@/shared/ui/separator";
55
import { Skeleton } from "@/shared/ui/skeleton";
6-
import { IconChevronDown, IconRefresh } from "@tabler/icons-react";
6+
import { IconChevronDown } from "@tabler/icons-react";
77
import {
88
getAgentProviders,
99
getModelProviders,
@@ -20,8 +20,10 @@ import type {
2020
function resolveStatus(
2121
entry: ProviderCatalogEntry,
2222
configuredIds: Set<string>,
23+
hasModelProvider: boolean,
2324
): ProviderSetupStatus {
24-
if (entry.id === "goose") return "built_in";
25+
if (entry.id === "goose")
26+
return hasModelProvider ? "built_in" : "needs_model";
2527
if (entry.category === "agent") return "not_installed";
2628
if (configuredIds.has(entry.id)) return "connected";
2729
return "not_configured";
@@ -30,18 +32,38 @@ function resolveStatus(
3032
function toDisplayInfo(
3133
entries: ProviderCatalogEntry[],
3234
configuredIds: Set<string>,
35+
hasModelProvider: boolean,
3336
): ProviderDisplayInfo[] {
3437
return entries.map((entry) => ({
3538
...entry,
36-
status: resolveStatus(entry, configuredIds),
39+
status: resolveStatus(entry, configuredIds, hasModelProvider),
3740
}));
3841
}
3942

40-
export function ProvidersSettings() {
43+
interface ProvidersSettingsProps {
44+
scrollContainerRef?: React.RefObject<HTMLElement | null>;
45+
onNeedsRestart?: () => void;
46+
}
47+
48+
export function ProvidersSettings({
49+
scrollContainerRef,
50+
onNeedsRestart,
51+
}: ProvidersSettingsProps) {
4152
const { t } = useTranslation(["settings", "common"]);
4253
const [showAllModels, setShowAllModels] = useState(false);
4354
const [modelOrder, setModelOrder] = useState<string[] | null>(null);
4455

56+
const modelsSectionRef = useRef<HTMLElement>(null);
57+
const scrollRafRef = useRef<number | null>(null);
58+
59+
useEffect(() => {
60+
return () => {
61+
if (scrollRafRef.current !== null) {
62+
cancelAnimationFrame(scrollRafRef.current);
63+
}
64+
};
65+
}, []);
66+
4567
const {
4668
configuredIds,
4769
loading,
@@ -50,18 +72,75 @@ export function ProvidersSettings() {
5072
getConfig,
5173
save,
5274
remove,
53-
restart,
5475
completeNativeSetup,
5576
} = useCredentials();
5677

78+
useEffect(() => {
79+
if (needsRestart) onNeedsRestart?.();
80+
}, [needsRestart, onNeedsRestart]);
81+
82+
const modelProviderIds = useMemo(
83+
() => new Set(getModelProviders().map((m) => m.id)),
84+
[],
85+
);
86+
87+
const hasModelProvider = useMemo(
88+
() => [...configuredIds].some((id) => modelProviderIds.has(id)),
89+
[configuredIds, modelProviderIds],
90+
);
91+
92+
const scrollToModels = useCallback(() => {
93+
const target = modelsSectionRef.current;
94+
if (!target) return;
95+
96+
if (scrollRafRef.current !== null) {
97+
cancelAnimationFrame(scrollRafRef.current);
98+
scrollRafRef.current = null;
99+
}
100+
101+
const scrollEl = scrollContainerRef?.current;
102+
if (!scrollEl) {
103+
target.scrollIntoView({ behavior: "smooth" });
104+
return;
105+
}
106+
107+
const targetTop =
108+
target.getBoundingClientRect().top -
109+
scrollEl.getBoundingClientRect().top +
110+
scrollEl.scrollTop -
111+
16;
112+
const start = scrollEl.scrollTop;
113+
const distance = targetTop - start;
114+
const duration = 500;
115+
let startTime: number | null = null;
116+
117+
function easeInOut(p: number) {
118+
return p < 0.5 ? 4 * p * p * p : 1 - (-2 * p + 2) ** 3 / 2;
119+
}
120+
121+
const step = (timestamp: number) => {
122+
if (!startTime) startTime = timestamp;
123+
const elapsed = timestamp - startTime;
124+
const progress = Math.min(elapsed / duration, 1);
125+
scrollEl.scrollTop = start + distance * easeInOut(progress);
126+
if (progress < 1) {
127+
scrollRafRef.current = requestAnimationFrame(step);
128+
} else {
129+
scrollRafRef.current = null;
130+
}
131+
};
132+
133+
scrollRafRef.current = requestAnimationFrame(step);
134+
}, [scrollContainerRef]);
135+
57136
const agents = useMemo(
58-
() => toDisplayInfo(getAgentProviders(), configuredIds),
59-
[configuredIds],
137+
() => toDisplayInfo(getAgentProviders(), configuredIds, hasModelProvider),
138+
[configuredIds, hasModelProvider],
60139
);
61140

62141
const allModels = useMemo(
63-
() => toDisplayInfo(getModelProviders(), configuredIds),
64-
[configuredIds],
142+
() => toDisplayInfo(getModelProviders(), configuredIds, hasModelProvider),
143+
[configuredIds, hasModelProvider],
65144
);
66145

67146
const sortedModels = useMemo(() => {
@@ -138,16 +217,6 @@ export function ProvidersSettings() {
138217
{t("providers.description")}
139218
</p>
140219

141-
{needsRestart && (
142-
<div className="mt-3 flex items-center gap-3 rounded-lg border border-accent bg-background-accent/30 px-3 py-2.5">
143-
<p className="flex-1 text-sm">{t("providers.restartMessage")}</p>
144-
<Button type="button" size="sm" onClick={() => void restart()}>
145-
<IconRefresh className="size-3.5" />
146-
{t("providers.restartButton")}
147-
</Button>
148-
</div>
149-
)}
150-
151220
<Separator className="my-4" />
152221

153222
<section>
@@ -162,14 +231,20 @@ export function ProvidersSettings() {
162231

163232
<div className="grid grid-cols-2 gap-3">
164233
{agents.map((agent) => (
165-
<AgentProviderCard key={agent.id} provider={agent} />
234+
<AgentProviderCard
235+
key={agent.id}
236+
provider={agent}
237+
onScrollToModels={
238+
agent.id === "goose" ? scrollToModels : undefined
239+
}
240+
/>
166241
))}
167242
</div>
168243
</section>
169244

170245
<Separator className="my-6" />
171246

172-
<section>
247+
<section ref={modelsSectionRef} className="scroll-mt-4">
173248
<div className="mb-3">
174249
<h4 className="text-sm font-semibold">
175250
{t("providers.models.title")}

0 commit comments

Comments
 (0)