centralize provider selection state in agentStore (#54)

* feat: centralize provider selection state in agentStore

Move provider discovery and selection from per-component state to a
shared store, ensuring the selected provider persists across screens
and is loaded once on app startup. Adds loading state for the provider
dropdown and removes hardcoded provider labels in favor of API data.

* fix: restore default provider after persona override
This commit is contained in:
morgmart
2026-04-01 11:37:03 -07:00
committed by GitHub
parent 396ab4154b
commit 38423edd55
11 changed files with 258 additions and 94 deletions
+2 -24
View File
@@ -18,6 +18,7 @@ import { useProjectStore } from "@/features/projects/stores/projectStore";
import { findReusableNewChatSession } from "@/features/chat/lib/newChat";
import { getSessionMessages } from "@/shared/api/chat";
import type { Tab } from "@/features/tabs/types";
import { useAppStartup } from "./hooks/useAppStartup";
export type AppView = "home" | "chat" | "skills" | "agents" | "projects";
@@ -75,30 +76,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
}
}, []);
// Load sessions, personas, and tab state on mount
useEffect(() => {
(async () => {
// Load personas eagerly so they're available in HomeScreen and ChatView
const personaStore = useAgentStore.getState();
personaStore.setPersonasLoading(true);
try {
const { listPersonas } = await import("@/shared/api/agents");
const personas = await listPersonas();
personaStore.setPersonas(personas);
} catch (err) {
console.error("Failed to load personas on startup:", err);
} finally {
personaStore.setPersonasLoading(false);
}
const { loadSessions, loadTabState } = useChatSessionStore.getState();
await loadSessions();
await loadTabState();
// Always start on the Home screen — tabs are restored in the tab bar
// but no tab is selected until the user clicks one.
useChatSessionStore.getState().setActiveTab(null);
})();
}, []);
useAppStartup();
useEffect(() => {
projectStore.fetchProjects();
+38
View File
@@ -0,0 +1,38 @@
import { useEffect } from "react";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
export function useAppStartup() {
useEffect(() => {
(async () => {
const store = useAgentStore.getState();
store.setPersonasLoading(true);
try {
const { listPersonas } = await import("@/shared/api/agents");
const personas = await listPersonas();
store.setPersonas(personas);
} catch (err) {
console.error("Failed to load personas on startup:", err);
} finally {
store.setPersonasLoading(false);
}
store.setProvidersLoading(true);
try {
const { discoverAcpProviders } = await import("@/shared/api/acp");
const providers = await discoverAcpProviders();
store.setProviders(providers);
} catch (err) {
console.error("Failed to load ACP providers on startup:", err);
} finally {
store.setProvidersLoading(false);
}
const { loadSessions, loadTabState } = useChatSessionStore.getState();
await loadSessions();
await loadTabState();
useChatSessionStore.getState().setActiveTab(null);
})();
}, []);
}
@@ -0,0 +1,31 @@
import { useCallback } from "react";
import { useAgentStore } from "../stores/agentStore";
export function useProviderSelection() {
const providers = useAgentStore((s) => s.providers);
const providersLoading = useAgentStore((s) => s.providersLoading);
const selectedProvider = useAgentStore((s) => s.selectedProvider);
const storeSetSelectedProvider = useAgentStore((s) => s.setSelectedProvider);
const setSelectedProvider = useCallback(
(providerId: string) => {
storeSetSelectedProvider(providerId, true);
},
[storeSetSelectedProvider],
);
const setSelectedProviderWithoutPersist = useCallback(
(providerId: string) => {
storeSetSelectedProvider(providerId, false);
},
[storeSetSelectedProvider],
);
return {
providers,
providersLoading,
selectedProvider,
setSelectedProvider,
setSelectedProviderWithoutPersist,
};
}
@@ -1,5 +1,35 @@
import { create } from "zustand";
import type { Persona, Agent } from "@/shared/types/agents";
import type { AcpProvider } from "@/shared/api/acp";
const PROVIDER_STORAGE_KEY = "goose:defaultProvider";
const FALLBACK_PROVIDER = "goose";
export function getStoredProvider(providers: AcpProvider[] = []): string {
try {
const storedProvider =
localStorage.getItem(PROVIDER_STORAGE_KEY) ?? FALLBACK_PROVIDER;
if (
providers.length === 0 ||
providers.some((provider) => provider.id === storedProvider)
) {
return storedProvider;
}
return providers[0]?.id ?? FALLBACK_PROVIDER;
} catch {
return providers[0]?.id ?? FALLBACK_PROVIDER;
}
}
function persistProvider(providerId: string): void {
try {
localStorage.setItem(PROVIDER_STORAGE_KEY, providerId);
} catch {
// localStorage may be unavailable
}
}
interface AgentStoreState {
// Personas
@@ -10,6 +40,13 @@ interface AgentStoreState {
agents: Agent[];
agentsLoading: boolean;
// ACP Providers (cached)
providers: AcpProvider[];
providersLoading: boolean;
// Selected provider (shared across all chat screens)
selectedProvider: string;
// Active agent for current chat
activeAgentId: string | null;
@@ -36,6 +73,11 @@ interface AgentStoreActions {
removeAgent: (id: string) => void;
setAgentsLoading: (loading: boolean) => void;
// Provider management
setProviders: (providers: AcpProvider[]) => void;
setProvidersLoading: (loading: boolean) => void;
setSelectedProvider: (providerId: string, persist?: boolean) => void;
// Active agent
setActiveAgent: (id: string | null) => void;
getActiveAgent: () => Agent | null;
@@ -63,6 +105,9 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
personasLoading: false,
agents: [],
agentsLoading: false,
providers: [],
providersLoading: false,
selectedProvider: getStoredProvider(),
activeAgentId: null,
isLoading: false,
personaEditorOpen: false,
@@ -112,6 +157,26 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
setAgentsLoading: (agentsLoading) => set({ agentsLoading }),
// Provider management
setProviders: (providers) => {
const { selectedProvider } = get();
const isValid = providers.some((p) => p.id === selectedProvider);
if (!isValid && providers.length > 0) {
const fallback = providers[0].id;
persistProvider(fallback);
set({ providers, selectedProvider: fallback });
} else {
set({ providers });
}
},
setProvidersLoading: (providersLoading) => set({ providersLoading }),
setSelectedProvider: (providerId, persist = true) => {
if (persist) {
persistProvider(providerId);
}
set({ selectedProvider: providerId });
},
// Active agent
setActiveAgent: (id) => set({ activeAgentId: id }),
@@ -78,7 +78,14 @@ export const useChatStore = create<ChatStore>((set, get) => ({
isConnected: false,
// Session management
setActiveSession: (sessionId) => set({ activeSessionId: sessionId }),
setActiveSession: (sessionId) =>
set((state) => ({
activeSessionId: sessionId,
chatState: state.activeSessionId !== sessionId ? "idle" : state.chatState,
streamingMessageId:
state.activeSessionId !== sessionId ? null : state.streamingMessageId,
error: state.activeSessionId !== sessionId ? null : state.error,
})),
// Message management
addMessage: (sessionId, message) =>
@@ -42,6 +42,7 @@ interface ChatInputProps {
onCreatePersona?: () => void;
// Provider (secondary -- auto-set by persona but overridable)
providers?: AcpProvider[];
providersLoading?: boolean;
selectedProvider?: string;
onProviderChange?: (providerId: string) => void;
// Model
@@ -79,6 +80,7 @@ export function ChatInput({
onPersonaChange,
onCreatePersona,
providers = [],
providersLoading = false,
selectedProvider = "goose",
onProviderChange,
currentModel = "Claude Sonnet 4",
@@ -256,6 +258,7 @@ export function ChatInput({
onPersonaChange={onPersonaChange}
onCreatePersona={onCreatePersona}
providers={providers}
providersLoading={providersLoading}
selectedProvider={selectedProvider}
onProviderChange={(id) => onProviderChange?.(id)}
currentModel={currentModel}
@@ -36,6 +36,7 @@ interface ChatInputSelectorProps {
sections: ChatInputSelectorSection[];
onValueChange: (value: string) => void;
contentWidth?: "trigger" | "wide";
disabled?: boolean;
}
export function ChatInputSelector({
@@ -50,6 +51,7 @@ export function ChatInputSelector({
sections,
onValueChange,
contentWidth = "trigger",
disabled,
}: ChatInputSelectorProps) {
const buttonSize = triggerSize === "sm" ? "xs" : "sm";
@@ -62,6 +64,7 @@ export function ChatInputSelector({
size={buttonSize}
aria-label={ariaLabel}
title={triggerTitle}
disabled={disabled}
className={cn(
"min-w-0",
triggerVariant === "default" && "justify-between",
@@ -29,18 +29,6 @@ const CREATE_PROJECT_VALUE = "__create_project__";
const CREATE_PROJECT_FROM_FOLDER_VALUE = "__create_project_from_folder__";
function formatProviderLabel(providerId: string) {
const knownLabels: Record<string, string> = {
goose: "Goose",
openai: "OpenAI",
claude: "Claude",
ollama: "Ollama",
custom: "Custom",
};
if (knownLabels[providerId]) {
return knownLabels[providerId];
}
return providerId
.split(/[-_\s]+/)
.filter(Boolean)
@@ -56,6 +44,7 @@ interface ChatInputToolbarProps {
onCreatePersona?: () => void;
// Provider
providers: AcpProvider[];
providersLoading?: boolean;
selectedProvider: string;
onProviderChange: (providerId: string) => void;
// Model
@@ -90,6 +79,7 @@ export function ChatInputToolbar({
onPersonaChange,
onCreatePersona,
providers,
providersLoading,
selectedProvider,
onProviderChange,
currentModel,
@@ -150,14 +140,15 @@ export function ChatInputToolbar({
<div className="flex items-center justify-between gap-2">
{/* Left side: pickers */}
<div className="flex items-center gap-0.5">
{availableProviderItems.length > 0 && (
{(availableProviderItems.length > 0 || providersLoading) && (
<ChatInputSelector
ariaLabel="Override provider"
value={selectedProvider}
triggerLabel={providerLabel}
triggerLabel={providersLoading ? "Loading..." : providerLabel}
triggerVariant="toolbar"
triggerSize="sm"
menuLabel="Provider Override"
disabled={providersLoading}
sections={[
{
items: availableProviderItems.map((provider) => ({
+15 -30
View File
@@ -5,8 +5,8 @@ import { LoadingGoose } from "./LoadingGoose";
import { useChat } from "../hooks/useChat";
import { useAcpStream } from "../hooks/useAcpStream";
import { useChatStore } from "../stores/chatStore";
import { discoverAcpProviders, type AcpProvider } from "@/shared/api/acp";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection";
import { useChatSessionStore } from "../stores/chatSessionStore";
import { getProject, type ProjectInfo } from "@/features/projects/api/projects";
import { useProjectStore } from "@/features/projects/stores/projectStore";
@@ -45,7 +45,14 @@ export function ChatView({
onCreateProjectFromFolder,
}: ChatViewProps) {
const [activeSessionId] = useState(() => sessionId ?? crypto.randomUUID());
const [providers, setProviders] = useState<AcpProvider[]>([]);
// Provider state from shared store
const {
providers,
providersLoading,
selectedProvider: globalSelectedProvider,
setSelectedProvider: setGlobalSelectedProvider,
} = useProviderSelection();
// Persona state
const personas = useAgentStore((s) => s.personas);
@@ -76,12 +83,12 @@ export function ChatView({
})),
[projects],
);
const effectiveProvider =
// For existing sessions, use their saved provider; otherwise use global selection
const selectedProvider =
session?.providerId ??
initialProvider ??
project?.preferredProvider ??
"goose";
const [selectedProvider, setSelectedProvider] = useState(effectiveProvider);
globalSelectedProvider;
const selectedPersona = personas.find((p) => p.id === selectedPersonaId);
const projectFolders = useMemo(
@@ -123,41 +130,18 @@ export function ChatView({
};
}, [session?.projectId, storedProject]);
useEffect(() => {
discoverAcpProviders()
.then((discovered) => {
setProviders(discovered);
setSelectedProvider((current) => {
if (
discovered.length > 0 &&
!discovered.some((p) => p.id === current)
) {
return discovered[0].id;
}
return current;
});
})
.catch(() => setProviders([]));
}, []);
useEffect(() => {
setSelectedProvider((current) =>
current === effectiveProvider ? current : effectiveProvider,
);
}, [effectiveProvider]);
const handleProviderChange = useCallback(
(providerId: string) => {
if (providerId === selectedProvider) {
return;
}
setSelectedProvider(providerId);
setGlobalSelectedProvider(providerId);
useChatSessionStore
.getState()
.updateSession(activeSessionId, { providerId });
},
[activeSessionId, selectedProvider],
[activeSessionId, selectedProvider, setGlobalSelectedProvider],
);
const handleProjectChange = useCallback(
@@ -342,6 +326,7 @@ export function ChatView({
onCreatePersona={handleCreatePersona}
// Providers (secondary)
providers={providers}
providersLoading={providersLoading}
selectedProvider={selectedProvider}
onProviderChange={handleProviderChange}
selectedProjectId={session?.projectId ?? null}
@@ -1,7 +1,11 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { HomeScreen } from "./HomeScreen";
const setSelectedProvider = vi.fn();
const setSelectedProviderWithoutPersist = vi.fn();
vi.mock("@/shared/api/acp", () => ({
discoverAcpProviders: vi.fn().mockResolvedValue([
{ id: "goose", label: "Goose" },
@@ -9,6 +13,19 @@ vi.mock("@/shared/api/acp", () => ({
]),
}));
vi.mock("@/features/agents/hooks/useProviderSelection", () => ({
useProviderSelection: () => ({
providers: [
{ id: "goose", label: "Goose" },
{ id: "openai", label: "OpenAI" },
],
providersLoading: false,
selectedProvider: "goose",
setSelectedProvider,
setSelectedProviderWithoutPersist,
}),
}));
// HomeScreen now reads personas from the agent store, not from ACP providers
vi.mock("@/features/agents/stores/agentStore", async (importOriginal) => {
const actual =
@@ -25,7 +42,29 @@ vi.mock("@/features/agents/stores/agentStore", async (importOriginal) => {
id: "builtin-solo",
displayName: "Solo",
systemPrompt: "You are Solo.",
provider: "openai",
description: null,
avatar: null,
createdBy: null,
source: "custom",
extensions: [],
metadata: null,
sortOrder: 0,
isDefault: false,
},
{
id: "builtin-goose",
displayName: "Goosey",
systemPrompt: "You are Goosey.",
isBuiltin: true,
description: null,
avatar: null,
createdBy: null,
source: "custom",
extensions: [],
metadata: null,
sortOrder: 1,
isDefault: false,
createdAt: "",
updatedAt: "",
},
@@ -44,6 +83,10 @@ describe("HomeScreen", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 2, 29, 14, 30, 0)); // 2:30 PM
localStorage.clear();
localStorage.setItem("goose:defaultProvider", "goose");
setSelectedProvider.mockReset();
setSelectedProviderWithoutPersist.mockReset();
});
it("renders the clock", () => {
@@ -80,4 +123,24 @@ describe("HomeScreen", () => {
screen.getByRole("button", { name: /select project/i }),
).toBeInTheDocument();
});
it("reverts to the stored provider when a persona override is cleared", async () => {
vi.useRealTimers();
const user = userEvent.setup();
render(<HomeScreen />);
await user.click(screen.getByRole("button", { name: /choose assistant/i }));
await user.click(screen.getByRole("menuitem", { name: /solo/i }));
expect(setSelectedProviderWithoutPersist).toHaveBeenLastCalledWith(
"openai",
);
await user.click(
screen.getByRole("button", { name: /clear active assistant/i }),
);
expect(setSelectedProviderWithoutPersist).toHaveBeenLastCalledWith("goose");
});
});
+25 -25
View File
@@ -1,8 +1,11 @@
import { useState, useEffect, useCallback } from "react";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import {
getStoredProvider,
useAgentStore,
} from "@/features/agents/stores/agentStore";
import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection";
import { ChatInput } from "@/features/chat/ui/ChatInput";
import { useProjectStore } from "@/features/projects/stores/projectStore";
import { discoverAcpProviders, type AcpProvider } from "@/shared/api/acp";
function HomeClock() {
const [time, setTime] = useState(new Date());
@@ -60,37 +63,33 @@ export function HomeScreen({
const greeting = getGreeting(hour);
const personas = useAgentStore((s) => s.personas);
const {
providers,
providersLoading,
selectedProvider,
setSelectedProvider,
setSelectedProviderWithoutPersist,
} = useProviderSelection();
const projects = useProjectStore((s) => s.projects);
const [selectedPersonaId, setSelectedPersonaId] = useState<string | null>(
null,
);
const [providers, setProviders] = useState<AcpProvider[]>([]);
const [selectedProvider, setSelectedProvider] = useState("goose");
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null,
);
const selectedPersona = personas.find((p) => p.id === selectedPersonaId);
useEffect(() => {
discoverAcpProviders()
.then((discovered) => {
setProviders(discovered);
setSelectedProvider((current) => {
if (
discovered.length > 0 &&
!discovered.some((provider) => provider.id === current)
) {
return discovered[0].id;
}
return current;
});
})
.catch(() => setProviders([]));
}, []);
const handlePersonaChange = useCallback(
(personaId: string | null) => {
setSelectedPersonaId(personaId);
const persona = personaId
? personas.find((candidate) => candidate.id === personaId)
: null;
const nextProvider = persona?.provider ?? getStoredProvider(providers);
useEffect(() => {
setSelectedProvider(selectedPersona?.provider ?? "goose");
}, [selectedPersona?.provider]);
setSelectedProviderWithoutPersist(nextProvider);
},
[personas, providers, setSelectedProviderWithoutPersist],
);
const handleCreatePersona = useCallback(() => {
useAgentStore.getState().openPersonaEditor();
@@ -127,9 +126,10 @@ export function HomeScreen({
onSend={handleSend}
personas={personas}
selectedPersonaId={selectedPersonaId}
onPersonaChange={setSelectedPersonaId}
onPersonaChange={handlePersonaChange}
onCreatePersona={handleCreatePersona}
providers={providers}
providersLoading={providersLoading}
selectedProvider={selectedProvider}
onProviderChange={setSelectedProvider}
selectedProjectId={selectedProjectId}