diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 206b873c89cb..6e588f5d08fc 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -477,6 +477,11 @@ impl ExtensionManager { pub async fn add_extension(&self, config: ExtensionConfig) -> ExtensionResult<()> { let config_name = config.key().to_string(); let sanitized_name = normalize(config_name.clone()); + + if self.extensions.lock().await.contains_key(&sanitized_name) { + return Ok(()); + } + let mut temp_dir = None; let client: Box = match &config { diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index cffd8c8625ce..927960cdc1ef 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -39,7 +39,7 @@ import PermissionSettingsView from './components/settings/permission/PermissionS import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/ExtensionsView'; import RecipesView from './components/recipes/RecipesView'; import { View, ViewOptions } from './utils/navigationUtils'; -import { NoProviderOrModelError, useAgent } from './hooks/useAgent'; + import { useNavigation } from './hooks/useNavigation'; import { errorMessage } from './utils/conversionUtils'; import { usePageViewTracking } from './hooks/useAnalytics'; @@ -51,10 +51,10 @@ function PageViewTracker() { } // Route Components -const HubRouteWrapper = ({ isExtensionsLoading }: { isExtensionsLoading: boolean }) => { +const HubRouteWrapper = () => { const setView = useNavigation(); - return ; + return ; }; const PairRouteWrapper = ({ @@ -363,15 +363,12 @@ const ExtensionsRoute = () => { export function AppInner() { const [fatalError, setFatalError] = useState(null); - const [agentWaitingMessage, setAgentWaitingMessage] = useState(null); const [isLoadingSharedSession, setIsLoadingSharedSession] = useState(false); const [sharedSessionError, setSharedSessionError] = useState(null); - const [isExtensionsLoading, setIsExtensionsLoading] = useState(false); const [didSelectProvider, setDidSelectProvider] = useState(false); const navigate = useNavigate(); const setView = useNavigation(); - const location = useLocation(); const [chat, setChat] = useState({ sessionId: '', @@ -384,7 +381,6 @@ export function AppInner() { const [activeSessionId, setActiveSessionId] = useState(null); const { addExtension } = useConfig(); - const { loadCurrentChat } = useAgent(); useEffect(() => { console.log('Sending reactReady signal to Electron'); @@ -398,28 +394,6 @@ export function AppInner() { } }, []); - // Handle URL parameters and deeplinks on app startup - const loadingHub = location.pathname === '/'; - useEffect(() => { - if (loadingHub) { - (async () => { - try { - const loadedChat = await loadCurrentChat({ - setAgentWaitingMessage, - setIsExtensionsLoading, - }); - setChat(loadedChat); - } catch (e) { - if (e instanceof NoProviderOrModelError) { - // the onboarding flow will trigger - } else { - throw e; - } - } - })(); - } - }, [loadCurrentChat, setAgentWaitingMessage, navigate, loadingHub, setChat]); - useEffect(() => { const handleOpenSharedSession = async (_event: IpcRendererEvent, ...args: unknown[]) => { const link = args[0] as string; @@ -616,18 +590,13 @@ export function AppInner() { path="/" element={ - + } > - } /> + } /> + } diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index fd7e259e9b06..5d70cf790ccb 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -96,7 +96,6 @@ interface ChatInputProps { initialPrompt?: string; toolCount: number; append?: (message: Message) => void; - isExtensionsLoading?: boolean; } export default function ChatInput({ @@ -121,7 +120,6 @@ export default function ChatInput({ initialPrompt, toolCount, append: _append, - isExtensionsLoading = false, }: ChatInputProps) { const [_value, setValue] = useState(initialValue); const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback @@ -1109,8 +1107,7 @@ export default function ChatInput({ isAnyImageLoading || isAnyDroppedFileLoading || isRecording || - isTranscribing || - isExtensionsLoading; + isTranscribing; // Queue management functions - no storage persistence, only in-memory const handleRemoveQueuedMessage = (messageId: string) => { @@ -1353,17 +1350,15 @@ export default function ChatInput({

- {isExtensionsLoading - ? 'Loading extensions...' - : isAnyImageLoading - ? 'Waiting for images to save...' - : isAnyDroppedFileLoading - ? 'Processing dropped files...' - : isRecording - ? 'Recording...' - : isTranscribing - ? 'Transcribing...' - : 'Send'} + {isAnyImageLoading + ? 'Waiting for images to save...' + : isAnyDroppedFileLoading + ? 'Processing dropped files...' + : isRecording + ? 'Recording...' + : isTranscribing + ? 'Transcribing...' + : 'Send'}

diff --git a/ui/desktop/src/components/Hub.tsx b/ui/desktop/src/components/Hub.tsx index 0f354ddd6801..cb792e6b962c 100644 --- a/ui/desktop/src/components/Hub.tsx +++ b/ui/desktop/src/components/Hub.tsx @@ -23,10 +23,8 @@ import { startNewSession } from '../sessions'; export default function Hub({ setView, - isExtensionsLoading, }: { setView: (view: View, viewOptions?: ViewOptions) => void; - isExtensionsLoading: boolean; }) { const handleSubmit = async (e: React.FormEvent) => { const customEvent = e as unknown as CustomEvent; @@ -59,7 +57,6 @@ export default function Hub({ messages={[]} disableAnimation={false} sessionCosts={undefined} - isExtensionsLoading={isExtensionsLoading} toolCount={0} /> diff --git a/ui/desktop/src/contexts/ChatContext.tsx b/ui/desktop/src/contexts/ChatContext.tsx index 8156fcc429a4..d7836e5c30de 100644 --- a/ui/desktop/src/contexts/ChatContext.tsx +++ b/ui/desktop/src/contexts/ChatContext.tsx @@ -14,7 +14,6 @@ interface ChatContextType { clearRecipe: () => void; // Context identification contextKey: string; // 'hub' or 'pair-{sessionId}' - agentWaitingMessage: string | null; } const ChatContext = createContext(undefined); @@ -24,14 +23,12 @@ interface ChatProviderProps { chat: ChatType; setChat: (chat: ChatType) => void; contextKey?: string; // Optional context key, defaults to 'hub' - agentWaitingMessage: string | null; } export const ChatProvider: React.FC = ({ children, chat, setChat, - agentWaitingMessage, contextKey = 'hub', }) => { const resetChat = () => { @@ -69,7 +66,6 @@ export const ChatProvider: React.FC = ({ setRecipe, clearRecipe, contextKey, - agentWaitingMessage, }; return {children}; diff --git a/ui/desktop/src/hooks/useAgent.ts b/ui/desktop/src/hooks/useAgent.ts deleted file mode 100644 index 33d1854e94fb..000000000000 --- a/ui/desktop/src/hooks/useAgent.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { useCallback, useRef, useState } from 'react'; -import { useConfig } from '../components/ConfigContext'; -import { ChatType } from '../types/chat'; -import { initializeSystem } from '../utils/providerUtils'; -import { - backupConfig, - initConfig, - readAllConfig, - Recipe, - recoverConfig, - resumeAgent, - startAgent, - validateConfig, -} from '../api'; - -export enum AgentState { - UNINITIALIZED = 'uninitialized', - INITIALIZING = 'initializing', - NO_PROVIDER = 'no_provider', - INITIALIZED = 'initialized', - ERROR = 'error', -} - -export interface InitializationContext { - recipe?: Recipe; - resumeSessionId?: string; - setAgentWaitingMessage: (msg: string | null) => void; - setIsExtensionsLoading?: (isLoading: boolean) => void; -} - -interface UseAgentReturn { - agentState: AgentState; - resetChat: () => void; - loadCurrentChat: (context: InitializationContext) => Promise; -} - -export class NoProviderOrModelError extends Error { - constructor() { - super('No provider or model configured'); - this.name = this.constructor.name; - } -} - -export function useAgent(): UseAgentReturn { - const [agentState, setAgentState] = useState(AgentState.UNINITIALIZED); - const [sessionId, setSessionId] = useState(null); - const initPromiseRef = useRef | null>(null); - const deletedSessionsRef = useRef>(new Set()); - const recipeIdFromConfig = useRef( - (window.appConfig.get('recipeId') as string | null | undefined) ?? null - ); - const recipeDeeplinkFromConfig = useRef( - (window.appConfig.get('recipeDeeplink') as string | null | undefined) ?? null - ); - const scheduledJobIdFromConfig = useRef( - (window.appConfig.get('scheduledJobId') as string | null | undefined) ?? null - ); - const { getExtensions, addExtension, read } = useConfig(); - - const resetChat = useCallback(() => { - setSessionId(null); - setAgentState(AgentState.UNINITIALIZED); - recipeIdFromConfig.current = null; - recipeDeeplinkFromConfig.current = null; - scheduledJobIdFromConfig.current = null; - deletedSessionsRef.current.clear(); - }, []); - - const agentIsInitialized = agentState === AgentState.INITIALIZED; - const currentChat = useCallback( - async (initContext: InitializationContext): Promise => { - // Skip deleted sessions - if ( - initContext.resumeSessionId && - deletedSessionsRef.current.has(initContext.resumeSessionId) - ) { - initContext.resumeSessionId = undefined; - - // Clear from URL - const url = new URL(window.location.href); - url.searchParams.delete('resumeSessionId'); - window.history.replaceState({}, '', url.toString()); - } - - if (sessionId && deletedSessionsRef.current.has(sessionId)) { - setSessionId(null); - } - - if (agentIsInitialized && sessionId && !deletedSessionsRef.current.has(sessionId)) { - let agentResponse; - try { - agentResponse = await resumeAgent({ - body: { - session_id: sessionId, - load_model_and_extensions: false, - }, - throwOnError: true, - }); - } catch { - // Mark session as deleted and clear state - deletedSessionsRef.current.add(sessionId); - setSessionId(null); - - // Clear from URL - const url = new URL(window.location.href); - if (url.searchParams.get('resumeSessionId')) { - url.searchParams.delete('resumeSessionId'); - window.history.replaceState({}, '', url.toString()); - } - } - - // Fall through to create new session - if (agentResponse?.data) { - const agentSession = agentResponse.data; - const messages = agentSession.conversation || []; - return { - sessionId: agentSession.id, - name: agentSession.recipe?.title || agentSession.name, - messages, - recipe: agentSession.recipe, - recipeParameterValues: agentSession.user_recipe_values || null, - }; - } - } - - if (initPromiseRef.current) { - return initPromiseRef.current; - } - - const initPromise = (async () => { - setAgentState(AgentState.INITIALIZING); - const agentWaitingMessage = initContext.setAgentWaitingMessage; - agentWaitingMessage('Agent is initializing'); - - try { - const config = window.electron.getConfig(); - const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER; - const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL; - - if (!provider || !model) { - setAgentState(AgentState.NO_PROVIDER); - throw new NoProviderOrModelError(); - } - - let agentResponse; - try { - agentResponse = initContext.resumeSessionId - ? await resumeAgent({ - body: { - session_id: initContext.resumeSessionId, - load_model_and_extensions: false, - }, - throwOnError: true, - }) - : await startAgent({ - body: { - working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string, - ...buildRecipeInput( - initContext.recipe, - recipeIdFromConfig.current, - recipeDeeplinkFromConfig.current - ), - }, - throwOnError: true, - }); - } catch (error) { - // If resuming fails, mark session as deleted and create new agent - if (initContext.resumeSessionId) { - deletedSessionsRef.current.add(initContext.resumeSessionId); - - // Clear from URL - const url = new URL(window.location.href); - url.searchParams.delete('resumeSessionId'); - window.history.replaceState({}, '', url.toString()); - - agentResponse = await startAgent({ - body: { - working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string, - ...buildRecipeInput( - initContext.recipe, - recipeIdFromConfig.current, - recipeDeeplinkFromConfig.current - ), - }, - throwOnError: true, - }); - - // Clear resume flag - initContext.resumeSessionId = undefined; - } else { - throw error; - } - } - - const agentSession = agentResponse.data; - if (!agentSession) { - throw Error('Failed to get session info'); - } - setSessionId(agentSession.id); - - if (!initContext.recipe && agentSession.recipe && scheduledJobIdFromConfig.current) { - agentSession.recipe = { - ...agentSession.recipe, - scheduledJobId: scheduledJobIdFromConfig.current, - isScheduledExecution: true, - } as Recipe; - scheduledJobIdFromConfig.current = null; - } - - recipeIdFromConfig.current = null; - recipeDeeplinkFromConfig.current = null; - - agentWaitingMessage('Agent is loading config'); - - await initConfig(); - - try { - await readAllConfig({ throwOnError: true }); - } catch (error) { - console.warn('Initial config read failed, attempting recovery:', error); - await handleConfigRecovery(); - } - - agentWaitingMessage('Extensions are loading'); - - const recipeForInit = initContext.recipe || agentSession.recipe || undefined; - await initializeSystem(agentSession.id, provider as string, model as string, { - getExtensions, - addExtension, - setIsExtensionsLoading: initContext.setIsExtensionsLoading, - recipeParameters: agentSession.user_recipe_values, - recipe: recipeForInit, - }); - - const recipe = initContext.recipe || agentSession.recipe; - const conversation = agentSession.conversation || []; - // If we're loading a recipe from initContext (new recipe load), start with empty messages - // Otherwise, use the messages from the session - const messages = initContext.recipe && !initContext.resumeSessionId ? [] : conversation; - let initChat: ChatType = { - sessionId: agentSession.id, - name: agentSession.recipe?.title || agentSession.name, - messages: messages, - recipe: recipe, - recipeParameterValues: agentSession.user_recipe_values || null, - }; - - setAgentState(AgentState.INITIALIZED); - - return initChat; - } catch (error) { - if ( - (error + '').includes('Failed to create provider') || - error instanceof NoProviderOrModelError - ) { - setAgentState(AgentState.NO_PROVIDER); - throw error; - } - setAgentState(AgentState.ERROR); - if (typeof error === 'object' && error !== null && 'message' in error) { - let error_message = error.message as string; - throw new Error(error_message); - } - throw error; - } finally { - agentWaitingMessage(null); - initPromiseRef.current = null; - } - })(); - - initPromiseRef.current = initPromise; - return initPromise; - }, - [agentIsInitialized, sessionId, read, getExtensions, addExtension] - ); - - return { - agentState, - resetChat, - loadCurrentChat: currentChat, - }; -} - -const handleConfigRecovery = async () => { - const configVersion = localStorage.getItem('configVersion'); - const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 3; - - if (shouldMigrateExtensions) { - try { - await backupConfig({ throwOnError: true }); - await initConfig(); - } catch (migrationError) { - console.error('Migration failed:', migrationError); - } - } - - try { - await validateConfig({ throwOnError: true }); - await readAllConfig({ throwOnError: true }); - } catch { - try { - await recoverConfig({ throwOnError: true }); - await readAllConfig({ throwOnError: true }); - } catch { - console.warn('Config recovery failed, reinitializing...'); - await initConfig(); - } - } -}; - -const buildRecipeInput = ( - recipeOverride?: Recipe, - recipeId?: string | null, - recipeDeeplink?: string | null -) => { - if (recipeId) { - return { recipe_id: recipeId }; - } - - if (recipeDeeplink) { - return { recipe_deeplink: recipeDeeplink }; - } - - if (recipeOverride) { - return { recipe: recipeOverride }; - } - - return {}; -};