From cd0946f0a95437ac2bec6fbe6449ae5c808ccff8 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 11 Dec 2025 10:06:52 -0800 Subject: [PATCH 01/24] bringing over extensions --- crates/goose-server/src/openapi.rs | 2 + crates/goose-server/src/routes/agent.rs | 65 ++++++++--- crates/goose-server/src/routes/session.rs | 46 +++++++- ui/desktop/openapi.json | 66 +++++++++++ ui/desktop/src/api/sdk.gen.ts | 4 +- ui/desktop/src/api/types.gen.ts | 41 +++++++ ui/desktop/src/components/Hub.tsx | 14 ++- .../BottomMenuExtensionSelection.tsx | 110 ++++++++++++++++-- ui/desktop/src/sessions.ts | 24 +++- ui/desktop/src/store/newChatState.ts | 64 +++++++++- 10 files changed, 397 insertions(+), 39 deletions(-) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 51f850e68f1a..122b41dbc617 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -372,6 +372,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::session::import_session, super::routes::session::update_session_user_recipe_values, super::routes::session::edit_message, + super::routes::session::get_session_extensions, super::routes::schedule::create_schedule, super::routes::schedule::list_schedules, super::routes::schedule::delete_schedule, @@ -427,6 +428,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::session::EditType, super::routes::session::EditMessageRequest, super::routes::session::EditMessageResponse, + super::routes::session::SessionExtensionsResponse, Message, MessageContent, MessageMetadata, diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index ae4b5321c785..ce43c2b77537 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -19,8 +19,9 @@ use goose::prompt_template::render_global_file; use goose::providers::create; use goose::recipe::Recipe; use goose::recipe_deeplink; +use goose::session::extension_data::ExtensionState; use goose::session::session_manager::SessionType; -use goose::session::{Session, SessionManager}; +use goose::session::{EnabledExtensionsState, Session, SessionManager}; use goose::{ agents::{extension::ToolInfo, extension_manager::get_parameter_names}, config::permission::PermissionLevel, @@ -65,6 +66,8 @@ pub struct StartAgentRequest { recipe_id: Option, #[serde(default)] recipe_deeplink: Option, + #[serde(default)] + extension_overrides: Option>, } #[derive(Deserialize, utoipa::ToSchema)] @@ -117,6 +120,7 @@ async fn start_agent( recipe, recipe_id, recipe_deeplink, + extension_overrides, } = payload; let original_recipe = if let Some(deeplink) = recipe_deeplink { @@ -162,30 +166,52 @@ async fn start_agent( } })?; - if let Some(recipe) = original_recipe { + // Initialize session with extensions (either overrides from hub or global defaults) + let extensions_to_use = + extension_overrides.unwrap_or_else(goose::config::get_enabled_extensions); + let mut extension_data = session.extension_data.clone(); + let extensions_state = EnabledExtensionsState::new(extensions_to_use); + if let Err(e) = extensions_state.to_extension_data(&mut extension_data) { + warn!("Failed to initialize session with extensions: {}", e); + } else { SessionManager::update_session(&session.id) - .recipe(Some(recipe)) + .extension_data(extension_data.clone()) .apply() .await .map_err(|err| { - error!("Failed to update session with recipe: {}", err); + error!("Failed to save initial extension state: {}", err); ErrorResponse { - message: format!("Failed to update session with recipe: {}", err), + message: format!("Failed to save initial extension state: {}", err), status: StatusCode::INTERNAL_SERVER_ERROR, } })?; + } - session = SessionManager::get_session(&session.id, false) + if let Some(recipe) = original_recipe { + SessionManager::update_session(&session.id) + .recipe(Some(recipe)) + .apply() .await .map_err(|err| { - error!("Failed to get updated session: {}", err); + error!("Failed to update session with recipe: {}", err); ErrorResponse { - message: format!("Failed to get updated session: {}", err), + message: format!("Failed to update session with recipe: {}", err), status: StatusCode::INTERNAL_SERVER_ERROR, } })?; } + // Refetch session to get all updates + session = SessionManager::get_session(&session.id, false) + .await + .map_err(|err| { + error!("Failed to get updated session: {}", err); + ErrorResponse { + message: format!("Failed to get updated session: {}", err), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; + Ok(Json(session)) } @@ -269,7 +295,12 @@ async fn resume_agent( }; let extensions_result = async { - let enabled_configs = goose::config::get_enabled_extensions(); + // Try to load session-specific extensions first, fall back to global config + let enabled_configs = + EnabledExtensionsState::from_extension_data(&session.extension_data) + .map(|state| state.extensions) + .unwrap_or_else(goose::config::get_enabled_extensions); + let agent_clone = agent.clone(); let extension_futures = enabled_configs @@ -288,7 +319,7 @@ async fn resume_agent( .collect::>(); futures::future::join_all(extension_futures).await; - Ok::<(), ErrorResponse>(()) // Fixed type annotation + Ok::<(), ErrorResponse>(()) }; let (provider_result, _) = tokio::join!(provider_result, extensions_result); @@ -616,15 +647,19 @@ async fn restore_agent_provider( async fn restore_agent_extensions( agent: Arc, - working_dir: &std::path::Path, + session: &Session, ) -> Result<(), ErrorResponse> { tracing::info!( "Setting GOOSE_WORKING_DIR environment variable to: {:?}", - working_dir + session.working_dir ); - std::env::set_var("GOOSE_WORKING_DIR", working_dir); + std::env::set_var("GOOSE_WORKING_DIR", &session.working_dir); + + // Try to load session-specific extensions first, fall back to global config + let enabled_configs = EnabledExtensionsState::from_extension_data(&session.extension_data) + .map(|state| state.extensions) + .unwrap_or_else(goose::config::get_enabled_extensions); - let enabled_configs = goose::config::get_enabled_extensions(); let extension_futures = enabled_configs .into_iter() .map(|config| { @@ -700,7 +735,7 @@ async fn restart_agent( tracing::info!("New agent created successfully"); let provider_result = restore_agent_provider(&agent, &session, &session_id); - let extensions_result = restore_agent_extensions(agent.clone(), &session.working_dir); + let extensions_result = restore_agent_extensions(agent.clone(), &session); let (provider_result, extensions_result) = tokio::join!(provider_result, extensions_result); provider_result?; diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index 4a850b896ff9..7f25702a4f49 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -9,9 +9,11 @@ use axum::{ routing::{delete, get, put}, Json, Router, }; +use goose::agents::ExtensionConfig; use goose::recipe::Recipe; +use goose::session::extension_data::ExtensionState; use goose::session::session_manager::SessionInsights; -use goose::session::{Session, SessionManager}; +use goose::session::{EnabledExtensionsState, Session, SessionManager}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; @@ -492,6 +494,44 @@ async fn edit_message( } } +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SessionExtensionsResponse { + extensions: Vec, +} + +#[utoipa::path( + get, + path = "/sessions/{session_id}/extensions", + params( + ("session_id" = String, Path, description = "Unique identifier for the session") + ), + responses( + (status = 200, description = "Session extensions retrieved successfully", body = SessionExtensionsResponse), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 404, description = "Session not found"), + (status = 500, description = "Internal server error") + ), + security( + ("api_key" = []) + ), + tag = "Session Management" +)] +async fn get_session_extensions( + Path(session_id): Path, +) -> Result, StatusCode> { + let session = SessionManager::get_session(&session_id, false) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + + // Try to get session-specific extensions, fall back to global config + let extensions = EnabledExtensionsState::from_extension_data(&session.extension_data) + .map(|state| state.extensions) + .unwrap_or_else(goose::config::get_enabled_extensions); + + Ok(Json(SessionExtensionsResponse { extensions })) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/sessions", get(list_sessions)) @@ -510,5 +550,9 @@ pub fn routes(state: Arc) -> Router { put(update_session_user_recipe_values), ) .route("/sessions/{session_id}/edit_message", post(edit_message)) + .route( + "/sessions/{session_id}/extensions", + get(get_session_extensions), + ) .with_state(state) } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 7cc78281b243..edc3f5021c68 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -2189,6 +2189,51 @@ ] } }, + "/sessions/{session_id}/extensions": { + "get": { + "tags": [ + "Session Management" + ], + "operationId": "get_session_extensions", + "parameters": [ + { + "name": "session_id", + "in": "path", + "description": "Unique identifier for the session", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Session extensions retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionExtensionsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "404": { + "description": "Session not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, "/sessions/{session_id}/name": { "put": { "tags": [ @@ -4987,6 +5032,20 @@ } } }, + "SessionExtensionsResponse": { + "type": "object", + "required": [ + "extensions" + ], + "properties": { + "extensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionConfig" + } + } + } + }, "SessionInsights": { "type": "object", "required": [ @@ -5143,6 +5202,13 @@ "working_dir" ], "properties": { + "extension_overrides": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionConfig" + }, + "nullable": true + }, "recipe": { "allOf": [ { diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index a9c503863273..162e7262bfac 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateSessionWorkingDirData, UpdateSessionWorkingDirErrors, UpdateSessionWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateSessionWorkingDirData, UpdateSessionWorkingDirErrors, UpdateSessionWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -377,6 +377,8 @@ export const editMessage = (options: Optio export const exportSession = (options: Options) => (options.client ?? client).get({ url: '/sessions/{session_id}/export', ...options }); +export const getSessionExtensions = (options: Options) => (options.client ?? client).get({ url: '/sessions/{session_id}/extensions', ...options }); + export const updateSessionName = (options: Options) => (options.client ?? client).put({ url: '/sessions/{session_id}/name', ...options, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 91dbecc1748c..bea1d1039779 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -785,6 +785,10 @@ export type SessionDisplayInfo = { workingDir: string; }; +export type SessionExtensionsResponse = { + extensions: Array; +}; + export type SessionInsights = { totalSessions: number; totalTokens: number; @@ -835,6 +839,7 @@ export type SlashCommandsResponse = { }; export type StartAgentRequest = { + extension_overrides?: Array | null; recipe?: Recipe | null; recipe_deeplink?: string | null; recipe_id?: string | null; @@ -2744,6 +2749,42 @@ export type ExportSessionResponses = { export type ExportSessionResponse = ExportSessionResponses[keyof ExportSessionResponses]; +export type GetSessionExtensionsData = { + body?: never; + path: { + /** + * Unique identifier for the session + */ + session_id: string; + }; + query?: never; + url: '/sessions/{session_id}/extensions'; +}; + +export type GetSessionExtensionsErrors = { + /** + * Unauthorized - Invalid or missing API key + */ + 401: unknown; + /** + * Session not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type GetSessionExtensionsResponses = { + /** + * Session extensions retrieved successfully + */ + 200: SessionExtensionsResponse; +}; + +export type GetSessionExtensionsResponse = GetSessionExtensionsResponses[keyof GetSessionExtensionsResponses]; + export type UpdateSessionNameData = { body: UpdateSessionNameRequest; path: { diff --git a/ui/desktop/src/components/Hub.tsx b/ui/desktop/src/components/Hub.tsx index 0f354ddd6801..e8fee4ae136d 100644 --- a/ui/desktop/src/components/Hub.tsx +++ b/ui/desktop/src/components/Hub.tsx @@ -19,7 +19,8 @@ import ChatInput from './ChatInput'; import { ChatState } from '../types/chatState'; import 'react-toastify/dist/ReactToastify.css'; import { View, ViewOptions } from '../utils/navigationUtils'; -import { startNewSession } from '../sessions'; +import { createSession } from '../sessions'; +import { useConfig } from './ConfigContext'; export default function Hub({ setView, @@ -28,12 +29,21 @@ export default function Hub({ setView: (view: View, viewOptions?: ViewOptions) => void; isExtensionsLoading: boolean; }) { + const { extensionsList } = useConfig(); + const handleSubmit = async (e: React.FormEvent) => { const customEvent = e as unknown as CustomEvent; const combinedTextFromInput = customEvent.detail?.value || ''; if (combinedTextFromInput.trim()) { - await startNewSession(combinedTextFromInput, setView); + const session = await createSession({ allExtensions: extensionsList }); + + setView('pair', { + disableAnimation: true, + initialMessage: combinedTextFromInput, + resumeSessionId: session.id, + }); + e.preventDefault(); } }; diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx index 05b0b11b782f..7a2e6e763c87 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx @@ -1,12 +1,18 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Puzzle } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../ui/dropdown-menu'; import { Input } from '../ui/input'; import { Switch } from '../ui/switch'; import { FixedExtensionEntry, useConfig } from '../ConfigContext'; -import { toggleExtension } from '../settings/extensions/extension-manager'; import { toastService } from '../../toasts'; import { getFriendlyTitle } from '../settings/extensions/subcomponents/ExtensionList'; +import { ExtensionConfig, getSessionExtensions } from '../../api'; +import { addToAgent, removeFromAgent } from '../settings/extensions/agent-api'; +import { + setExtensionOverride, + getExtensionOverride, + getExtensionOverrides, +} from '../../store/newChatState'; interface BottomMenuExtensionSelectionProps { sessionId: string; @@ -15,10 +21,52 @@ interface BottomMenuExtensionSelectionProps { export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionSelectionProps) => { const [searchQuery, setSearchQuery] = useState(''); const [isOpen, setIsOpen] = useState(false); - const { extensionsList, addExtension } = useConfig(); + const [sessionExtensions, setSessionExtensions] = useState([]); + const [hubUpdateTrigger, setHubUpdateTrigger] = useState(0); // Force re-render for hub updates + const { extensionsList: allExtensions } = useConfig(); + const isHubView = !sessionId; // True when in hub/new chat view + + // Fetch session-specific extensions or use global defaults + useEffect(() => { + const fetchExtensions = async () => { + if (!sessionId) { + // In hub view, don't fetch, we'll use global + overrides + return; + } + + try { + const response = await getSessionExtensions({ + path: { session_id: sessionId }, + }); + + if (response.data?.extensions) { + setSessionExtensions(response.data.extensions); + } + } catch (error) { + console.error('Failed to fetch session extensions:', error); + } + }; + + fetchExtensions(); + }, [sessionId, isOpen]); // Refetch when dropdown opens const handleToggle = useCallback( async (extensionConfig: FixedExtensionEntry) => { + if (isHubView) { + // In hub view, just track the override locally using newChatState + const currentState = getExtensionOverride(extensionConfig.name) ?? extensionConfig.enabled; + setExtensionOverride(extensionConfig.name, !currentState); + + // Force re-render by incrementing the trigger + setHubUpdateTrigger((prev) => prev + 1); + + toastService.success({ + title: 'Extension Updated', + msg: `${extensionConfig.name} will be ${!currentState ? 'enabled' : 'disabled'} in new chats`, + }); + return; + } + if (!sessionId) { toastService.error({ title: 'Extension Toggle Error', @@ -29,15 +77,22 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS } try { - const toggleDirection = extensionConfig.enabled ? 'toggleOff' : 'toggleOn'; - - await toggleExtension({ - toggle: toggleDirection, - extensionConfig: extensionConfig, - addToConfig: addExtension, - toastOptions: { silent: false }, - sessionId: sessionId, + if (extensionConfig.enabled) { + // Disable extension - only in session, not global config + await removeFromAgent(extensionConfig.name, sessionId, true); + } else { + // Enable extension - only in session, not global config + await addToAgent(extensionConfig, sessionId, true); + } + + // Refetch extensions after toggle + const response = await getSessionExtensions({ + path: { session_id: sessionId }, }); + + if (response.data?.extensions) { + setSessionExtensions(response.data.extensions); + } } catch (error) { toastService.error({ title: 'Extension Error', @@ -46,9 +101,37 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS }); } }, - [sessionId, addExtension] + [sessionId, isHubView] ); + // Merge all available extensions with session-specific or hub override state + const extensionsList = useMemo(() => { + const hubOverrides = getExtensionOverrides(); + + if (isHubView) { + // In hub view, show global extension states with local overrides + return allExtensions.map( + (ext) => + ({ + ...ext, + enabled: hubOverrides.has(ext.name) ? hubOverrides.get(ext.name)! : ext.enabled, + }) as FixedExtensionEntry + ); + } + + // In session view, show session-specific states + const sessionExtensionNames = new Set(sessionExtensions.map((ext) => ext.name)); + + return allExtensions.map( + (ext) => + ({ + ...ext, + enabled: sessionExtensionNames.has(ext.name), + }) as FixedExtensionEntry + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allExtensions, sessionExtensions, isHubView, hubUpdateTrigger]); + const filteredExtensions = useMemo(() => { return extensionsList.filter((ext) => { const query = searchQuery.toLowerCase(); @@ -115,6 +198,9 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS className="h-8 text-sm" autoFocus /> +

+ {isHubView ? 'Extensions for new chats' : 'Extensions for this chat session'} +

{sortedExtensions.length === 0 ? ( diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index f2c1943ae495..7a68678200f7 100644 --- a/ui/desktop/src/sessions.ts +++ b/ui/desktop/src/sessions.ts @@ -1,6 +1,12 @@ -import { Session, startAgent, restartAgent } from './api'; +import { Session, startAgent, restartAgent, ExtensionConfig } from './api'; import type { setViewType } from './hooks/useNavigation'; -import { getWorkingDir } from './store/newChatState'; +import { + getWorkingDir, + getExtensionConfigsWithOverrides, + clearExtensionOverrides, + hasExtensionOverrides, +} from './store/newChatState'; +import type { FixedExtensionEntry } from './components/ConfigContext'; export function resumeSession(session: Session, setView: setViewType) { setView('pair', { @@ -12,16 +18,18 @@ export function resumeSession(session: Session, setView: setViewType) { export async function createSession(options?: { recipeId?: string; recipeDeeplink?: string; + allExtensions?: FixedExtensionEntry[]; }): Promise { const body: { working_dir: string; recipe_id?: string; recipe_deeplink?: string; + extension_overrides?: ExtensionConfig[]; } = { working_dir: getWorkingDir(), }; - // Note: We intentionally don't clear newChatState here + // Note: We intentionally don't clear workingDir from newChatState here // so that new sessions in the same window continue to use the last selected directory if (options?.recipeId) { @@ -30,6 +38,16 @@ export async function createSession(options?: { body.recipe_deeplink = options.recipeDeeplink; } + // Get extension configs with any overrides applied + if (options?.allExtensions && hasExtensionOverrides()) { + const extensionConfigs = getExtensionConfigsWithOverrides(options.allExtensions); + if (extensionConfigs.length > 0) { + body.extension_overrides = extensionConfigs; + } + // Clear the overrides after using them + clearExtensionOverrides(); + } + const newAgent = await startAgent({ body, throwOnError: true, diff --git a/ui/desktop/src/store/newChatState.ts b/ui/desktop/src/store/newChatState.ts index dc7c461e7f0e..1f3a4865f0e1 100644 --- a/ui/desktop/src/store/newChatState.ts +++ b/ui/desktop/src/store/newChatState.ts @@ -2,16 +2,19 @@ // Acts as a cache that can be updated from UI or synced from session // Resets on page refresh - defaults to window.appConfig.get('GOOSE_WORKING_DIR') +import type { ExtensionConfig } from '../api'; + +// Map of extension name -> enabled state (overrides from hub view) +type ExtensionOverrides = Map; + interface NewChatState { workingDir: string | null; - // Future additions: - // extensions?: string[]; - // provider?: string; - // model?: string; + extensionOverrides: ExtensionOverrides; } const state: NewChatState = { workingDir: null, + extensionOverrides: new Map(), }; export function setWorkingDir(dir: string): void { @@ -26,6 +29,57 @@ export function clearWorkingDir(): void { state.workingDir = null; } +// Extension override functions +export function setExtensionOverride(name: string, enabled: boolean): void { + state.extensionOverrides.set(name, enabled); +} + +export function getExtensionOverride(name: string): boolean | undefined { + return state.extensionOverrides.get(name); +} + +export function hasExtensionOverrides(): boolean { + return state.extensionOverrides.size > 0; +} + +export function getExtensionOverrides(): ExtensionOverrides { + return state.extensionOverrides; +} + +export function clearExtensionOverrides(): void { + state.extensionOverrides.clear(); +} + +// Get extension configs with overrides applied +export function getExtensionConfigsWithOverrides( + allExtensions: Array<{ name: string; enabled: boolean } & Omit> +): ExtensionConfig[] { + if (state.extensionOverrides.size === 0) { + // No overrides, return global enabled extensions + return allExtensions + .filter((ext) => ext.enabled) + .map((ext) => { + const { enabled: _enabled, ...config } = ext; + return config as ExtensionConfig; + }); + } + + // Apply overrides + return allExtensions + .filter((ext) => { + // Check if we have an override for this extension + if (state.extensionOverrides.has(ext.name)) { + return state.extensionOverrides.get(ext.name); + } + // Otherwise use the global enabled state + return ext.enabled; + }) + .map((ext) => { + const { enabled: _enabled, ...config } = ext; + return config as ExtensionConfig; + }); +} + // Generic getters/setters for future extensibility export function getNewChatState(): Readonly { return { ...state }; @@ -33,5 +87,5 @@ export function getNewChatState(): Readonly { export function resetNewChatState(): void { state.workingDir = null; - // Reset future fields here + state.extensionOverrides.clear(); } From 605bd555567ff51ede9c696343a9776b8185e1b8 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 11 Dec 2025 14:31:12 -0800 Subject: [PATCH 02/24] add working_dir to moim --- crates/goose/src/agents/agent.rs | 1 + crates/goose/src/agents/extension_manager.rs | 8 ++++++-- crates/goose/src/agents/moim.rs | 16 ++++++++++++---- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index a1edb82d27f2..248c4d21d59e 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -999,6 +999,7 @@ impl Agent { let conversation_with_moim = super::moim::inject_moim( conversation.clone(), &self.extension_manager, + &working_dir, ).await; let mut stream = Self::stream_response_from_provider( diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index d8d7341eee19..7dfc7b749ae9 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -1223,9 +1223,13 @@ impl ExtensionManager { .map(|ext| ext.get_client()) } - pub async fn collect_moim(&self) -> Option { + pub async fn collect_moim(&self, working_dir: &std::path::Path) -> Option { let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); - let mut content = format!("\nDatetime: {}\n", timestamp); + let mut content = format!( + "\nDatetime: {}\nWorking directory: {}\n", + timestamp, + working_dir.display() + ); let extensions = self.extensions.lock().await; for (name, extension) in extensions.iter() { diff --git a/crates/goose/src/agents/moim.rs b/crates/goose/src/agents/moim.rs index d920f3b7dc74..886087cb25e8 100644 --- a/crates/goose/src/agents/moim.rs +++ b/crates/goose/src/agents/moim.rs @@ -2,6 +2,7 @@ use crate::agents::extension_manager::ExtensionManager; use crate::conversation::message::Message; use crate::conversation::{fix_conversation, Conversation}; use rmcp::model::Role; +use std::path::Path; // Test-only utility. Do not use in production code. No `test` directive due to call outside crate. thread_local! { @@ -11,12 +12,13 @@ thread_local! { pub async fn inject_moim( conversation: Conversation, extension_manager: &ExtensionManager, + working_dir: &Path, ) -> Conversation { if SKIP.with(|f| f.get()) { return conversation; } - if let Some(moim) = extension_manager.collect_moim().await { + if let Some(moim) = extension_manager.collect_moim(working_dir).await { let mut messages = conversation.messages().clone(); let idx = messages .iter() @@ -45,17 +47,19 @@ pub async fn inject_moim( mod tests { use super::*; use rmcp::model::CallToolRequestParam; + use std::path::PathBuf; #[tokio::test] async fn test_moim_injection_before_assistant() { let em = ExtensionManager::new_without_provider(); + let working_dir = PathBuf::from("/test/dir"); let conv = Conversation::new_unvalidated(vec![ Message::user().with_text("Hello"), Message::assistant().with_text("Hi"), Message::user().with_text("Bye"), ]); - let result = inject_moim(conv, &em).await; + let result = inject_moim(conv, &em, &working_dir).await; let msgs = result.messages(); assert_eq!(msgs.len(), 3); @@ -70,14 +74,16 @@ mod tests { .join(""); assert!(merged_content.contains("Hello")); assert!(merged_content.contains("")); + assert!(merged_content.contains("Working directory: /test/dir")); } #[tokio::test] async fn test_moim_injection_no_assistant() { let em = ExtensionManager::new_without_provider(); + let working_dir = PathBuf::from("/test/dir"); let conv = Conversation::new_unvalidated(vec![Message::user().with_text("Hello")]); - let result = inject_moim(conv, &em).await; + let result = inject_moim(conv, &em, &working_dir).await; assert_eq!(result.messages().len(), 1); @@ -89,11 +95,13 @@ mod tests { .join(""); assert!(merged_content.contains("Hello")); assert!(merged_content.contains("")); + assert!(merged_content.contains("Working directory: /test/dir")); } #[tokio::test] async fn test_moim_with_tool_calls() { let em = ExtensionManager::new_without_provider(); + let working_dir = PathBuf::from("/test/dir"); let conv = Conversation::new_unvalidated(vec![ Message::user().with_text("Search for something"), @@ -119,7 +127,7 @@ mod tests { Message::user().with_tool_response("search_2", Ok(vec![])), ]); - let result = inject_moim(conv, &em).await; + let result = inject_moim(conv, &em, &working_dir).await; let msgs = result.messages(); assert_eq!(msgs.len(), 6); From e8946ccda542d9f441d0d7a072016891a52eda4b Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 11 Dec 2025 15:58:20 -0800 Subject: [PATCH 03/24] Remove ALPHA flag from extensions switcher in bottom menu --- ui/desktop/src/components/ChatInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 239a6ed71b94..b2427ef09778 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -1523,7 +1523,7 @@ export default function ChatInput({
- {sessionId && process.env.ALPHA && ( + {sessionId && ( <>
From b444271a78bd0a182f5656c6f4b13d65dfc78ef3 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 11 Dec 2025 15:59:40 -0800 Subject: [PATCH 04/24] Show extensions switcher in hub view for selecting extensions before starting chat --- ui/desktop/src/components/ChatInput.tsx | 8 ++------ .../bottom_menu/BottomMenuExtensionSelection.tsx | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index b2427ef09778..de3b0301c16e 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -1523,12 +1523,8 @@ export default function ChatInput({
- {sessionId && ( - <> -
- - - )} +
+ {sessionId && messages.length > 0 && ( <>
diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx index 7a2e6e763c87..70056200aa82 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx @@ -15,7 +15,7 @@ import { } from '../../store/newChatState'; interface BottomMenuExtensionSelectionProps { - sessionId: string; + sessionId: string | null; } export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionSelectionProps) => { From 38e37225af8431d60c54681c57fcfa2329235e4b Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Mon, 15 Dec 2025 11:04:07 -0800 Subject: [PATCH 05/24] regenerate ts --- ui/desktop/src/api/sdk.gen.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 162e7262bfac..670432463ac3 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateSessionWorkingDirData, UpdateSessionWorkingDirErrors, UpdateSessionWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateSessionWorkingDirData, UpdateSessionWorkingDirErrors, UpdateSessionWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -36,6 +36,24 @@ export const agentAddExtension = (options: } }); +export const callTool = (options: Options) => (options.client ?? client).post({ + url: '/agent/call_tool', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +export const readResource = (options: Options) => (options.client ?? client).post({ + url: '/agent/read_resource', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + export const agentRemoveExtension = (options: Options) => (options.client ?? client).post({ url: '/agent/remove_extension', ...options, From ff2b582c82853eaaa9a634a02dcfe716ad2c25bd Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Mon, 15 Dec 2025 13:32:52 -0800 Subject: [PATCH 06/24] add resuming session loads session-specific extensions correctly --- crates/goose-server/src/routes/agent.rs | 58 +++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index ce56af06514d..ea91fd6a1ad1 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -557,11 +557,15 @@ async fn agent_add_extension( } })?; - let agent = state.get_agent(request.session_id).await?; + let agent = state.get_agent(request.session_id.clone()).await?; agent - .add_extension(request.config, Some(session.working_dir)) + .add_extension(request.config, Some(session.working_dir.clone())) .await .map_err(|e| ErrorResponse::internal(format!("Failed to add extension: {}", e)))?; + + // Persist the updated extension state to the session + persist_session_extensions(&agent, &request.session_id, &session).await?; + Ok(StatusCode::OK) } @@ -580,8 +584,22 @@ async fn agent_remove_extension( State(state): State>, Json(request): Json, ) -> Result { - let agent = state.get_agent(request.session_id).await?; + let session = SessionManager::get_session(&request.session_id, false) + .await + .map_err(|err| { + error!("Failed to get session for remove_extension: {}", err); + ErrorResponse { + message: format!("Failed to get session: {}", err), + status: StatusCode::NOT_FOUND, + } + })?; + + let agent = state.get_agent(request.session_id.clone()).await?; agent.remove_extension(&request.name).await?; + + // Persist the updated extension state to the session + persist_session_extensions(&agent, &request.session_id, &session).await?; + Ok(StatusCode::OK) } @@ -613,6 +631,40 @@ async fn stop_agent( Ok(StatusCode::OK) } +async fn persist_session_extensions( + agent: &Arc, + session_id: &str, + session: &Session, +) -> Result<(), ErrorResponse> { + let current_extensions = agent.extension_manager.get_extension_configs().await; + let extensions_state = EnabledExtensionsState::new(current_extensions); + + let mut extension_data = session.extension_data.clone(); + extensions_state + .to_extension_data(&mut extension_data) + .map_err(|e| { + error!("Failed to serialize extension state: {}", e); + ErrorResponse { + message: format!("Failed to serialize extension state: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; + + SessionManager::update_session(session_id) + .extension_data(extension_data) + .apply() + .await + .map_err(|e| { + error!("Failed to persist extension state: {}", e); + ErrorResponse { + message: format!("Failed to persist extension state: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; + + Ok(()) +} + async fn restore_agent_provider( agent: &Arc, session: &Session, From 079f480034eedf440f616a3cae6076af6fa87a59 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Tue, 16 Dec 2025 14:43:46 -0800 Subject: [PATCH 07/24] add subtle transition when toggling extensions and update default text --- .../BottomMenuExtensionSelection.tsx | 101 +++++++++++------- .../components/extensions/ExtensionsView.tsx | 6 +- .../components/sessions/SessionListView.tsx | 53 +++++++++ .../subcomponents/ExtensionList.tsx | 2 +- 4 files changed, 124 insertions(+), 38 deletions(-) diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx index 9b6af8fddcce..27bcbe2d8651 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { Puzzle } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../ui/dropdown-menu'; import { Input } from '../ui/input'; @@ -22,15 +22,26 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS const [searchQuery, setSearchQuery] = useState(''); const [isOpen, setIsOpen] = useState(false); const [sessionExtensions, setSessionExtensions] = useState([]); - const [hubUpdateTrigger, setHubUpdateTrigger] = useState(0); // Force re-render for hub updates + const [hubUpdateTrigger, setHubUpdateTrigger] = useState(0); + const [isTransitioning, setIsTransitioning] = useState(false); + const [pendingSort, setPendingSort] = useState(false); + const sortTimeoutRef = useRef | null>(null); const { extensionsList: allExtensions } = useConfig(); - const isHubView = !sessionId; // True when in hub/new chat view + const isHubView = !sessionId; + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (sortTimeoutRef.current) { + clearTimeout(sortTimeoutRef.current); + } + }; + }, []); // Fetch session-specific extensions or use global defaults useEffect(() => { const fetchExtensions = async () => { if (!sessionId) { - // In hub view, don't fetch, we'll use global + overrides return; } @@ -48,17 +59,31 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS }; fetchExtensions(); - }, [sessionId, isOpen]); // Refetch when dropdown opens + }, [sessionId, isOpen]); const handleToggle = useCallback( async (extensionConfig: FixedExtensionEntry) => { + // Start transition animation + setIsTransitioning(true); + if (isHubView) { - // In hub view, just track the override locally using extensionOverrides const currentState = getExtensionOverride(extensionConfig.name) ?? extensionConfig.enabled; setExtensionOverride(extensionConfig.name, !currentState); - // Force re-render by incrementing the trigger - setHubUpdateTrigger((prev) => prev + 1); + // Mark that we need to re-sort after delay + setPendingSort(true); + + // Clear any existing timeout + if (sortTimeoutRef.current) { + clearTimeout(sortTimeoutRef.current); + } + + // Delay the re-sort to allow animation + sortTimeoutRef.current = setTimeout(() => { + setHubUpdateTrigger((prev) => prev + 1); + setPendingSort(false); + setIsTransitioning(false); + }, 800); toastService.success({ title: 'Extension Updated', @@ -68,6 +93,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS } if (!sessionId) { + setIsTransitioning(false); toastService.error({ title: 'Extension Toggle Error', msg: 'No active session found. Please start a chat session first.', @@ -78,22 +104,31 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS try { if (extensionConfig.enabled) { - // Disable extension - only in session, not global config await removeFromAgent(extensionConfig.name, sessionId, true); } else { - // Enable extension - only in session, not global config await addToAgent(extensionConfig, sessionId, true); } - // Refetch extensions after toggle - const response = await getSessionExtensions({ - path: { session_id: sessionId }, - }); + setPendingSort(true); - if (response.data?.extensions) { - setSessionExtensions(response.data.extensions); + if (sortTimeoutRef.current) { + clearTimeout(sortTimeoutRef.current); } + + sortTimeoutRef.current = setTimeout(async () => { + const response = await getSessionExtensions({ + path: { session_id: sessionId }, + }); + + if (response.data?.extensions) { + setSessionExtensions(response.data.extensions); + } + setPendingSort(false); + setIsTransitioning(false); + }, 800); } catch (error) { + setIsTransitioning(false); + setPendingSort(false); toastService.error({ title: 'Extension Error', msg: `Failed to ${extensionConfig.enabled ? 'disable' : 'enable'} ${extensionConfig.name}`, @@ -109,7 +144,6 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS const hubOverrides = getExtensionOverrides(); if (isHubView) { - // In hub view, show global extension states with local overrides return allExtensions.map( (ext) => ({ @@ -119,7 +153,6 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS ); } - // In session view, show session-specific states const sessionExtensionNames = new Set(sessionExtensions.map((ext) => ext.name)); return allExtensions.map( @@ -143,24 +176,11 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS }, [extensionsList, searchQuery]); const sortedExtensions = useMemo(() => { - const getTypePriority = (type: string): number => { - const priorities: Record = { - builtin: 0, - platform: 1, - frontend: 2, - }; - return priorities[type] ?? Number.MAX_SAFE_INTEGER; - }; - return [...filteredExtensions].sort((a, b) => { - // First sort by priority type - const typeDiff = getTypePriority(a.type) - getTypePriority(b.type); - if (typeDiff !== 0) return typeDiff; - - // Then sort by enabled status (enabled first) + // Primary sort: enabled first if (a.enabled !== b.enabled) return a.enabled ? -1 : 1; - // Finally sort alphabetically + // Secondary sort: alphabetically by name return a.name.localeCompare(b.name); }); }, [filteredExtensions]); @@ -175,7 +195,12 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS onOpenChange={(open) => { setIsOpen(open); if (!open) { - setSearchQuery(''); // Reset search when closing + setSearchQuery(''); + if (sortTimeoutRef.current) { + clearTimeout(sortTimeoutRef.current); + } + setIsTransitioning(false); + setPendingSort(false); } }} > @@ -202,7 +227,11 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS {isHubView ? 'Extensions for new chats' : 'Extensions for this chat session'}

-
+
{sortedExtensions.length === 0 ? (
{searchQuery ? 'no extensions found' : 'no extensions available'} @@ -211,7 +240,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS sortedExtensions.map((ext) => (
handleToggle(ext)} title={ext.description || ext.name} > diff --git a/ui/desktop/src/components/extensions/ExtensionsView.tsx b/ui/desktop/src/components/extensions/ExtensionsView.tsx index 8177441ae85e..c0f7cdb98ee0 100644 --- a/ui/desktop/src/components/extensions/ExtensionsView.tsx +++ b/ui/desktop/src/components/extensions/ExtensionsView.tsx @@ -113,11 +113,15 @@ export default function ExtensionsView({

Extensions

-

+

These extensions use the Model Context Protocol (MCP). They can expand Goose's capabilities using three main components: Prompts, Resources, and Tools. ⌘F/Ctrl+F to search.

+

+ Extensions enabled here are used as the default for new chats. You can also toggle + extensions using the extensions menu during chat. +

{/* Action Buttons */}
diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index bfd6ba385cb3..6d1e135799d9 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -10,6 +10,7 @@ import { Download, Upload, ExternalLink, + Puzzle, } from 'lucide-react'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; @@ -22,6 +23,7 @@ import { groupSessionsByDate, type DateGroup } from '../../utils/dateUtils'; import { Skeleton } from '../ui/skeleton'; import { toast } from 'react-toastify'; import { ConfirmationModal } from '../ui/ConfirmationModal'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip'; import { deleteSession, exportSession, @@ -29,8 +31,28 @@ import { listSessions, Session, updateSessionName, + ExtensionConfig, + ExtensionData, } from '../../api'; +// Helper to extract extension names from session's extension_data +function getSessionExtensionNames(extensionData: ExtensionData): string[] { + try { + // extension_data structure: { "enabled_extensions": { "v0": { "extensions": [...] } } } + const enabledExtensions = extensionData?.['enabled_extensions'] as + | Record + | undefined; + if (!enabledExtensions) return []; + + const v0Data = enabledExtensions['v0']; + if (!v0Data?.extensions) return []; + + return v0Data.extensions.map((ext) => ext.name); + } catch { + return []; + } +} + interface EditSessionModalProps { session: Session | null; isOpen: boolean; @@ -547,6 +569,12 @@ const SessionListView: React.FC = React.memo( [onOpenInNewWindow, session] ); + // Get extension names for this session + const extensionNames = useMemo( + () => getSessionExtensionNames(session.extension_data), + [session.extension_data] + ); + return ( = React.memo( {(session.total_tokens || 0).toLocaleString()}
)} + {extensionNames.length > 0 && ( + + + +
e.stopPropagation()} + > + + {extensionNames.length} +
+
+ +
+
Extensions:
+
    + {extensionNames.map((name) => ( +
  • {name}
  • + ))} +
+
+
+
+
+ )}
diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx index a2ab0b121fbf..f29cde546a85 100644 --- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx +++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx @@ -55,7 +55,7 @@ export default function ExtensionList({

- Enabled Extensions ({sortedEnabledExtensions.length}) + Default Extensions ({sortedEnabledExtensions.length})

{sortedEnabledExtensions.map((extension) => ( From ea6b76600a3d89edcddb1e05f067058f2ecc1d8c Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Tue, 16 Dec 2025 15:17:02 -0800 Subject: [PATCH 08/24] fix extensions not showing --- .../src/components/sessions/SessionListView.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index 6d1e135799d9..359872781f70 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -38,16 +38,17 @@ import { // Helper to extract extension names from session's extension_data function getSessionExtensionNames(extensionData: ExtensionData): string[] { try { - // extension_data structure: { "enabled_extensions": { "v0": { "extensions": [...] } } } - const enabledExtensions = extensionData?.['enabled_extensions'] as - | Record + // extension_data structure: { "enabled_extensions.v0": { "extensions": [...] } } + const v0Data = extensionData?.['enabled_extensions.v0'] as + | { extensions?: ExtensionConfig[] } | undefined; - if (!enabledExtensions) return []; - - const v0Data = enabledExtensions['v0']; if (!v0Data?.extensions) return []; - return v0Data.extensions.map((ext) => ext.name); + return v0Data.extensions.map((ext) => { + // display_name exists on some ExtensionConfig variants but not all + const displayName = (ext as { display_name?: string }).display_name; + return displayName || ext.name; + }); } catch { return []; } From 3768ac4d5db636144354e543bcd755fbf347b71c Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Tue, 16 Dec 2025 16:06:03 -0800 Subject: [PATCH 09/24] make global extension enabling for default only not current session --- .../components/extensions/ExtensionsView.tsx | 20 +-- .../settings/extensions/ExtensionsSection.tsx | 34 +---- .../settings/extensions/extension-manager.ts | 124 +++++++++++++----- .../components/settings/extensions/index.ts | 2 + 4 files changed, 101 insertions(+), 79 deletions(-) diff --git a/ui/desktop/src/components/extensions/ExtensionsView.tsx b/ui/desktop/src/components/extensions/ExtensionsView.tsx index c0f7cdb98ee0..25934351b02c 100644 --- a/ui/desktop/src/components/extensions/ExtensionsView.tsx +++ b/ui/desktop/src/components/extensions/ExtensionsView.tsx @@ -1,5 +1,4 @@ import { View, ViewOptions } from '../../utils/navigationUtils'; -import { useChatContext } from '../../contexts/ChatContext'; import ExtensionsSection from '../settings/extensions/ExtensionsSection'; import { ExtensionConfig } from '../../api'; import { MainPanelLayout } from '../Layout/MainPanelLayout'; @@ -14,7 +13,7 @@ import { ExtensionFormData, createExtensionConfig, } from '../settings/extensions/utils'; -import { activateExtension } from '../settings/extensions'; +import { activateExtensionDefault } from '../settings/extensions'; import { useConfig } from '../ConfigContext'; import { SearchView } from '../conversation/SearchView'; @@ -34,14 +33,7 @@ export default function ExtensionsView({ const [refreshKey, setRefreshKey] = useState(0); const [searchTerm, setSearchTerm] = useState(''); const { addExtension } = useConfig(); - const chatContext = useChatContext(); - const sessionId = chatContext?.chat.sessionId || ''; - if (!sessionId) { - console.error('ExtensionsView: No session ID available'); - } - - // Only trigger refresh when deep link config changes AND we don't need to show env vars useEffect(() => { if (viewOptions.deepLinkConfig && !viewOptions.showEnvVars) { setRefreshKey((prevKey) => prevKey + 1); @@ -80,19 +72,12 @@ export default function ExtensionsView({ // Close the modal immediately handleModalClose(); - if (!sessionId) { - console.warn('Cannot activate extension without session'); - setRefreshKey((prevKey) => prevKey + 1); - return; - } - const extensionConfig = createExtensionConfig(formData); try { - await activateExtension({ + await activateExtensionDefault({ addToConfig: addExtension, extensionConfig: extensionConfig, - sessionId: sessionId, }); // Trigger a refresh of the extensions list setRefreshKey((prevKey) => prevKey + 1); @@ -151,7 +136,6 @@ export default function ExtensionsView({ setSearchTerm(term)} placeholder="Search extensions..."> { @@ -134,10 +129,9 @@ export default function ExtensionsSection({ const extensionConfig = createExtensionConfig(formData); try { - await activateExtension({ + await activateExtensionDefault({ addToConfig: addExtension, extensionConfig: extensionConfig, - sessionId: sessionId, }); setPendingActivationExtensions((prev) => { const updated = new Set(prev); @@ -177,42 +171,28 @@ export default function ExtensionsSection({ const originalName = selectedExtension.name; try { - await updateExtension({ - enabled: formData.enabled, - extensionConfig: extensionConfig, - addToConfig: addExtension, - removeFromConfig: removeExtension, - originalName: originalName, - sessionId: sessionId, - }); + if (originalName !== extensionConfig.name) { + await removeExtension(originalName); + } + await addExtension(extensionConfig.name, extensionConfig, formData.enabled); } catch (error) { console.error('Failed to update extension:', error); - // We don't reopen the modal on failure } finally { - // Refresh the extensions list regardless of success or failure await fetchExtensions(); } }; const handleDeleteExtension = async (name: string) => { - // Capture the selected extension before closing the modal - const extensionToDelete = selectedExtension; - - // Close the modal immediately handleModalClose(); try { await deleteExtension({ name, removeFromConfig: removeExtension, - sessionId: sessionId, - extensionConfig: extensionToDelete ?? undefined, }); } catch (error) { console.error('Failed to delete extension:', error); - // We don't reopen the modal on failure } finally { - // Refresh the extensions list regardless of success or failure await fetchExtensions(); } }; diff --git a/ui/desktop/src/components/settings/extensions/extension-manager.ts b/ui/desktop/src/components/settings/extensions/extension-manager.ts index 37b072894846..e30b971b3dc2 100644 --- a/ui/desktop/src/components/settings/extensions/extension-manager.ts +++ b/ui/desktop/src/components/settings/extensions/extension-manager.ts @@ -135,12 +135,9 @@ interface UpdateExtensionProps { removeFromConfig: (name: string) => Promise; extensionConfig: ExtensionConfig; originalName?: string; - sessionId: string; + sessionId?: string; } -/** - * Updates an extension configuration, handling name changes - */ export async function updateExtension({ enabled, addToConfig, @@ -149,40 +146,33 @@ export async function updateExtension({ originalName, sessionId, }: UpdateExtensionProps) { - // Sanitize the new name to match the behavior when adding extensions const sanitizedNewName = sanitizeName(extensionConfig.name); const sanitizedOriginalName = originalName ? sanitizeName(originalName) : undefined; - - // Check if the sanitized name has changed const nameChanged = sanitizedOriginalName && sanitizedOriginalName !== sanitizedNewName; if (nameChanged) { - // Handle name change: remove old extension and add new one - - // First remove the old extension from agent (using original name) - try { - await removeFromAgent(originalName!, sessionId, false); - } catch (error) { - console.error('Failed to remove old extension from agent during rename:', error); - // Continue with the process even if agent removal fails + if (sessionId) { + try { + await removeFromAgent(originalName!, sessionId, false); + } catch (error) { + console.error('Failed to remove old extension from agent during rename:', error); + // Continue with the process even if agent removal fails + } } - // Remove old extension from config (using original name) try { - await removeFromConfig(originalName!); // We know originalName is not undefined here because nameChanged is true + await removeFromConfig(originalName!); } catch (error) { console.error('Failed to remove old extension from config during rename:', error); - throw error; // This is more critical, so we throw + throw error; } - // Create a copy of the extension config with the sanitized name const sanitizedExtensionConfig = { ...extensionConfig, name: sanitizedNewName, }; - // Add new extension with sanitized name - if (enabled) { + if (enabled && sessionId) { try { await addToAgent(sanitizedExtensionConfig, sessionId, false); } catch (error) { @@ -191,7 +181,6 @@ export async function updateExtension({ } } - // Add to config with sanitized name try { await addToConfig(sanitizedNewName, sanitizedExtensionConfig, enabled); } catch (error) { @@ -205,18 +194,16 @@ export async function updateExtension({ msg: `Successfully updated ${sanitizedNewName} extension`, }); } else { - // Create a copy of the extension config with the sanitized name const sanitizedExtensionConfig = { ...extensionConfig, name: sanitizedNewName, }; - if (enabled) { + if (enabled && sessionId) { try { await addToAgent(sanitizedExtensionConfig, sessionId, false); } catch (error) { console.error('[updateExtension]: Failed to add extension to agent during update:', error); - // Failed to add to agent -- show that error to user and do not update the config file throw error; } @@ -228,7 +215,6 @@ export async function updateExtension({ throw error; } - // show a toast that it was successfully updated toastService.success({ title: `Update extension`, msg: `Successfully updated ${sanitizedNewName} extension`, @@ -237,11 +223,10 @@ export async function updateExtension({ try { await addToConfig(sanitizedNewName, sanitizedExtensionConfig, enabled); } catch (error) { - console.error('[updateExtension]: Failed to update disabled extension in config:', error); + console.error('[updateExtension]: Failed to update extension in config:', error); throw error; } - // show a toast that it was successfully updated toastService.success({ title: `Update extension`, msg: `Successfully updated ${sanitizedNewName} extension`, @@ -347,7 +332,7 @@ export async function toggleExtension({ interface DeleteExtensionProps { name: string; removeFromConfig: (name: string) => Promise; - sessionId: string; + sessionId?: string; extensionConfig?: ExtensionConfig; } @@ -363,11 +348,13 @@ export async function deleteExtension({ const isBuiltin = extensionConfig ? isBuiltinExtension(extensionConfig) : false; let agentRemoveError = null; - try { - await removeFromAgent(name, sessionId, true); - } catch (error) { - console.error('Failed to remove extension from agent during deletion:', error); - agentRemoveError = error; + if (sessionId) { + try { + await removeFromAgent(name, sessionId, true); + } catch (error) { + console.error('Failed to remove extension from agent during deletion:', error); + agentRemoveError = error; + } } try { @@ -390,3 +377,72 @@ export async function deleteExtension({ throw agentRemoveError; } } + +interface ToggleExtensionDefaultProps { + toggle: 'toggleOn' | 'toggleOff'; + extensionConfig: ExtensionConfig; + addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; +} + +export async function toggleExtensionDefault({ + toggle, + extensionConfig, + addToConfig, +}: ToggleExtensionDefaultProps) { + const isBuiltin = isBuiltinExtension(extensionConfig); + const enabled = toggle === 'toggleOn'; + + try { + await addToConfig(extensionConfig.name, extensionConfig, enabled); + if (enabled) { + trackExtensionEnabled(extensionConfig.name, true, undefined, isBuiltin); + } else { + trackExtensionDisabled(extensionConfig.name, true, undefined, isBuiltin); + } + toastService.success({ + title: extensionConfig.name, + msg: enabled ? 'Extension enabled as default' : 'Extension disabled as default', + }); + } catch (error) { + console.error('Failed to update extension default in config:', error); + if (enabled) { + trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin); + } else { + trackExtensionDisabled(extensionConfig.name, false, getErrorType(error), isBuiltin); + } + toastService.error({ + title: extensionConfig.name, + msg: 'Failed to update extension default', + }); + throw error; + } +} + +interface ActivateExtensionDefaultProps { + addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; + extensionConfig: ExtensionConfig; +} + +export async function activateExtensionDefault({ + addToConfig, + extensionConfig, +}: ActivateExtensionDefaultProps): Promise { + const isBuiltin = isBuiltinExtension(extensionConfig); + + try { + await addToConfig(extensionConfig.name, extensionConfig, true); + trackExtensionAdded(extensionConfig.name, true, undefined, isBuiltin); + toastService.success({ + title: extensionConfig.name, + msg: 'Extension added as default', + }); + } catch (error) { + console.error('Failed to add extension to config:', error); + trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin); + toastService.error({ + title: extensionConfig.name, + msg: 'Failed to add extension', + }); + throw error; + } +} diff --git a/ui/desktop/src/components/settings/extensions/index.ts b/ui/desktop/src/components/settings/extensions/index.ts index 5469fc52ad69..f0fdfbd7986d 100644 --- a/ui/desktop/src/components/settings/extensions/index.ts +++ b/ui/desktop/src/components/settings/extensions/index.ts @@ -4,9 +4,11 @@ export { DEFAULT_EXTENSION_TIMEOUT, nameToKey } from './utils'; // Export extension management functions export { activateExtension, + activateExtensionDefault, addToAgentOnStartup, updateExtension, toggleExtension, + toggleExtensionDefault, deleteExtension, } from './extension-manager'; From 2c6e15f151d19deb5b6a7ef05269918265e027b2 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Wed, 17 Dec 2025 18:35:02 -0800 Subject: [PATCH 10/24] extension loading per session working --- crates/goose-server/src/openapi.rs | 3 + crates/goose-server/src/routes/agent.rs | 51 ++- crates/goose-server/src/routes/agent_utils.rs | 41 +- ui/desktop/openapi.json | 62 +++- ui/desktop/src/App.tsx | 157 +++----- ui/desktop/src/api/types.gen.ts | 23 +- ui/desktop/src/components/BaseChat.tsx | 5 +- ui/desktop/src/components/ChatInput.tsx | 51 ++- .../GroupedExtensionLoadingToast.tsx | 27 +- ui/desktop/src/components/Hub.tsx | 57 ++- ui/desktop/src/components/LoadingGoose.tsx | 2 + .../BottomMenuExtensionSelection.tsx | 85 +++-- .../components/bottom_menu/DirSwitcher.tsx | 10 +- .../components/extensions/ExtensionsView.tsx | 2 +- .../settings/extensions/ExtensionsSection.tsx | 35 +- .../extensions/extension-manager.test.ts | 255 ------------- .../settings/extensions/extension-manager.ts | 350 +----------------- .../components/settings/extensions/index.ts | 11 +- .../subcomponents/ExtensionItem.tsx | 14 +- .../subcomponents/ExtensionList.tsx | 3 - ui/desktop/src/hooks/useAgent.ts | 19 +- ui/desktop/src/hooks/useChatStream.ts | 24 +- ui/desktop/src/sessions.ts | 22 +- ui/desktop/src/types/chatState.ts | 1 + ui/desktop/src/utils/extensionErrorUtils.ts | 47 +++ ui/desktop/src/utils/navigationUtils.ts | 5 +- ui/desktop/src/utils/providerUtils.ts | 76 ---- 27 files changed, 452 insertions(+), 986 deletions(-) delete mode 100644 ui/desktop/src/components/settings/extensions/extension-manager.test.ts diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index e0950365a6eb..c52a52fd7d98 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -535,6 +535,9 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::UpdateFromSessionRequest, super::routes::agent::AddExtensionRequest, super::routes::agent::RemoveExtensionRequest, + super::routes::agent::ResumeAgentResponse, + super::routes::agent::RestartAgentResponse, + super::routes::agent_utils::ExtensionLoadResult, super::routes::setup::SetupResponse, super::tunnel::TunnelInfo, super::tunnel::TunnelState, diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index d53820f24c96..a0e885a53e15 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -1,5 +1,6 @@ use crate::routes::agent_utils::{ persist_session_extensions, restore_agent_extensions, restore_agent_provider, + ExtensionLoadResult, }; use crate::routes::errors::ErrorResponse; use crate::routes::recipe_utils::{ @@ -135,6 +136,18 @@ pub struct CallToolResponse { is_error: bool, } +#[derive(Serialize, utoipa::ToSchema)] +pub struct ResumeAgentResponse { + pub session: Session, + #[serde(skip_serializing_if = "Option::is_none")] + pub extension_results: Option>, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct RestartAgentResponse { + pub extension_results: Vec, +} + #[utoipa::path( post, path = "/agent/start", @@ -257,7 +270,7 @@ async fn start_agent( path = "/agent/resume", request_body = ResumeAgentRequest, responses( - (status = 200, description = "Agent started successfully", body = Session), + (status = 200, description = "Agent started successfully", body = ResumeAgentResponse), (status = 400, description = "Bad request - invalid working directory"), (status = 401, description = "Unauthorized - invalid secret key"), (status = 500, description = "Internal server error") @@ -266,7 +279,7 @@ async fn start_agent( async fn resume_agent( State(state): State>, Json(payload): Json, -) -> Result, ErrorResponse> { +) -> Result, ErrorResponse> { goose::posthog::set_session_context("desktop", true); let session = SessionManager::get_session(&payload.session_id, true) @@ -279,7 +292,7 @@ async fn resume_agent( } })?; - if payload.load_model_and_extensions { + let extension_results = if payload.load_model_and_extensions { let agent = state .get_agent_for_route(payload.session_id.clone()) .await @@ -289,14 +302,19 @@ async fn resume_agent( })?; let provider_result = restore_agent_provider(&agent, &session, &payload.session_id); - let extensions_result = restore_agent_extensions(agent.clone(), &session); + let extensions_future = restore_agent_extensions(agent.clone(), &session); - let (provider_result, extensions_result) = tokio::join!(provider_result, extensions_result); + let (provider_result, extension_results) = tokio::join!(provider_result, extensions_future); provider_result?; - extensions_result?; - } + Some(extension_results) + } else { + None + }; - Ok(Json(session)) + Ok(Json(ResumeAgentResponse { + session, + extension_results, + })) } #[utoipa::path( @@ -596,7 +614,7 @@ async fn restart_agent_internal( state: &Arc, session_id: &str, session: &Session, -) -> Result<(), ErrorResponse> { +) -> Result, ErrorResponse> { // Remove existing agent (ignore error if not found) let _ = state.agent_manager.remove_session(session_id).await; @@ -609,11 +627,10 @@ async fn restart_agent_internal( })?; let provider_result = restore_agent_provider(&agent, session, session_id); - let extensions_result = restore_agent_extensions(agent.clone(), session); + let extensions_future = restore_agent_extensions(agent.clone(), session); - let (provider_result, extensions_result) = tokio::join!(provider_result, extensions_result); + let (provider_result, extension_results) = tokio::join!(provider_result, extensions_future); provider_result?; - extensions_result?; let context: HashMap<&str, Value> = HashMap::new(); let desktop_prompt = @@ -645,7 +662,7 @@ async fn restart_agent_internal( } agent.extend_system_prompt(update_prompt).await; - Ok(()) + Ok(extension_results) } #[utoipa::path( @@ -653,7 +670,7 @@ async fn restart_agent_internal( path = "/agent/restart", request_body = RestartAgentRequest, responses( - (status = 200, description = "Agent restarted successfully"), + (status = 200, description = "Agent restarted successfully", body = RestartAgentResponse), (status = 401, description = "Unauthorized - invalid secret key"), (status = 404, description = "Session not found"), (status = 500, description = "Internal server error") @@ -662,7 +679,7 @@ async fn restart_agent_internal( async fn restart_agent( State(state): State>, Json(payload): Json, -) -> Result { +) -> Result, ErrorResponse> { let session_id = payload.session_id.clone(); let session = SessionManager::get_session(&session_id, false) @@ -675,9 +692,9 @@ async fn restart_agent( } })?; - restart_agent_internal(&state, &session_id, &session).await?; + let extension_results = restart_agent_internal(&state, &session_id, &session).await?; - Ok(StatusCode::OK) + Ok(Json(RestartAgentResponse { extension_results })) } #[utoipa::path( diff --git a/crates/goose-server/src/routes/agent_utils.rs b/crates/goose-server/src/routes/agent_utils.rs index 6f0190b950db..1f5d9a35cd94 100644 --- a/crates/goose-server/src/routes/agent_utils.rs +++ b/crates/goose-server/src/routes/agent_utils.rs @@ -6,9 +6,18 @@ use goose::model::ModelConfig; use goose::providers::create; use goose::session::extension_data::ExtensionState; use goose::session::{EnabledExtensionsState, Session, SessionManager}; +use serde::Serialize; use std::sync::Arc; use tracing::{error, warn}; +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +pub struct ExtensionLoadResult { + pub name: String, + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + pub async fn restore_agent_provider( agent: &Arc, session: &Session, @@ -57,14 +66,18 @@ pub async fn restore_agent_provider( pub async fn restore_agent_extensions( agent: Arc, session: &Session, -) -> Result<(), ErrorResponse> { +) -> Vec { // Set the agent's working directory before adding extensions agent.set_working_dir(session.working_dir.clone()).await; // Try to load session-specific extensions first, fall back to global config - let enabled_configs = EnabledExtensionsState::from_extension_data(&session.extension_data) + let session_extensions = EnabledExtensionsState::from_extension_data(&session.extension_data); + let enabled_configs = session_extensions .map(|state| state.extensions) - .unwrap_or_else(goose::config::get_enabled_extensions); + .unwrap_or_else(|| { + tracing::info!("restore_agent_extensions: falling back to global config"); + goose::config::get_enabled_extensions() + }); let extension_futures = enabled_configs .into_iter() @@ -73,16 +86,28 @@ pub async fn restore_agent_extensions( let agent_ref = agent.clone(); async move { - if let Err(e) = agent_ref.add_extension(config_clone.clone()).await { - warn!("Failed to load extension {}: {}", config_clone.name(), e); + let name = config_clone.name().to_string(); + match agent_ref.add_extension(config_clone).await { + Ok(_) => ExtensionLoadResult { + name, + success: true, + error: None, + }, + Err(e) => { + let error_msg = e.to_string(); + warn!("Failed to load extension {}: {}", name, error_msg); + ExtensionLoadResult { + name, + success: false, + error: Some(error_msg), + } + } } - Ok::<_, ErrorResponse>(()) } }) .collect::>(); - futures::future::join_all(extension_futures).await; - Ok(()) + futures::future::join_all(extension_futures).await } pub async fn persist_session_extensions( diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 66695e111655..aeaa5cc0e97a 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -227,7 +227,14 @@ }, "responses": { "200": { - "description": "Agent restarted successfully" + "description": "Agent restarted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestartAgentResponse" + } + } + } }, "401": { "description": "Unauthorized - invalid secret key" @@ -263,7 +270,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/ResumeAgentResponse" } } } @@ -3664,6 +3671,25 @@ } ] }, + "ExtensionLoadResult": { + "type": "object", + "required": [ + "name", + "success" + ], + "properties": { + "error": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, "ExtensionQuery": { "type": "object", "required": [ @@ -4887,6 +4913,20 @@ } } }, + "RestartAgentResponse": { + "type": "object", + "required": [ + "extension_results" + ], + "properties": { + "extension_results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionLoadResult" + } + } + } + }, "ResumeAgentRequest": { "type": "object", "required": [ @@ -4902,6 +4942,24 @@ } } }, + "ResumeAgentResponse": { + "type": "object", + "required": [ + "session" + ], + "properties": { + "extension_results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionLoadResult" + }, + "nullable": true + }, + "session": { + "$ref": "#/components/schemas/Session" + } + } + }, "RetryConfig": { "type": "object", "description": "Configuration for retry logic in recipe execution", diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 768cba99c9b1..08d720fb2b5f 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -51,128 +51,78 @@ function PageViewTracker() { } // Route Components -const HubRouteWrapper = ({ isExtensionsLoading }: { isExtensionsLoading: boolean }) => { +const HubRouteWrapper = () => { const setView = useNavigation(); - - return ; + return ; }; const PairRouteWrapper = ({ chat, setChat, - activeSessionId, - setActiveSessionId, }: { chat: ChatType; setChat: (chat: ChatType) => void; - activeSessionId: string | null; - setActiveSessionId: (id: string | null) => void; }) => { + const { extensionsList } = useConfig(); const location = useLocation(); - const routeState = - (location.state as PairRouteState) || (window.history.state as PairRouteState) || {}; + const routeState = (location.state as PairRouteState) || {}; const [searchParams, setSearchParams] = useSearchParams(); - - // Capture initialMessage in local state to survive route state being cleared by setSearchParams - const [capturedInitialMessage, setCapturedInitialMessage] = useState( - undefined - ); - const [lastSessionId, setLastSessionId] = useState(undefined); - const [isCreatingSession, setIsCreatingSession] = useState(false); - + const [initialMessage] = useState(routeState.initialMessage); const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined; const recipeId = searchParams.get('recipeId') ?? undefined; const recipeDeeplinkFromConfig = window.appConfig?.get('recipeDeeplink') as string | undefined; - // Determine which session ID to use: - // 1. From route state (when navigating from Hub with a new session) - // 2. From URL params (when resuming a session or after refresh) - // 3. From active session state (when navigating back from other routes) - // 4. From the existing chat state - const sessionId = - routeState.resumeSessionId || resumeSessionId || activeSessionId || chat.sessionId; - - // Use route state if available, otherwise use captured state - const initialMessage = routeState.initialMessage || capturedInitialMessage; - - useEffect(() => { - if (routeState.initialMessage) { - setCapturedInitialMessage(routeState.initialMessage); - } - }, [routeState.initialMessage]); + // Session ID comes from URL (set by Hub before navigating, or from session list/recipe deeplink) + const [sessionId, setSessionId] = useState( + routeState.resumeSessionId || resumeSessionId || chat.sessionId || undefined + ); + // Handle recipe deeplinks - create session if needed useEffect(() => { - // Create a new session if we have an initialMessage, recipeId, or recipeDeeplink from config but no sessionId - if ( - (initialMessage || recipeId || recipeDeeplinkFromConfig) && - !sessionId && - !isCreatingSession - ) { - console.log( - '[PairRouteWrapper] Creating new session for initialMessage, recipeId, or recipeDeeplink from config' - ); - setIsCreatingSession(true); - + if ((recipeId || recipeDeeplinkFromConfig) && !sessionId) { (async () => { try { const newSession = await createSession(getInitialWorkingDir(), { recipeId, recipeDeeplink: recipeDeeplinkFromConfig, + allExtensions: extensionsList, }); - - setSearchParams((prev) => { - prev.set('resumeSessionId', newSession.id); - // Remove recipeId from URL after session is created - prev.delete('recipeId'); - return prev; - }); - setActiveSessionId(newSession.id); + setSessionId(newSession.id); + setSearchParams( + (prev) => { + prev.set('resumeSessionId', newSession.id); + prev.delete('recipeId'); + return prev; + }, + { replace: true } + ); } catch (error) { - console.error('[PairRouteWrapper] Failed to create session:', error); - } finally { - setIsCreatingSession(false); + console.error('Failed to create session for recipe:', error); } })(); } - }, [ - initialMessage, - recipeId, - recipeDeeplinkFromConfig, - sessionId, - isCreatingSession, - setSearchParams, - setActiveSessionId, - ]); - - // Clear captured initialMessage when sessionId actually changes to a different session - useEffect(() => { - if (sessionId !== lastSessionId) { - setLastSessionId(sessionId); - if (!routeState.initialMessage) { - setCapturedInitialMessage(undefined); - } - } - }, [sessionId, lastSessionId, routeState.initialMessage]); + }, [recipeId, recipeDeeplinkFromConfig, sessionId, extensionsList, setSearchParams]); - // Update URL with session ID when on /pair route (for refresh support) + // Sync URL with session ID for refresh support useEffect(() => { if (sessionId && sessionId !== resumeSessionId) { - setSearchParams((prev) => { - prev.set('resumeSessionId', sessionId); - return prev; - }); + setSearchParams( + (prev) => { + prev.set('resumeSessionId', sessionId); + return prev; + }, + { replace: true } + ); } }, [sessionId, resumeSessionId, setSearchParams]); - // Update active session state when session ID changes - useEffect(() => { - if (sessionId && sessionId !== activeSessionId) { - setActiveSessionId(sessionId); - } - }, [sessionId, activeSessionId, setActiveSessionId]); - return ( - + ); }; @@ -361,7 +311,6 @@ export function AppInner() { 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(); @@ -376,9 +325,6 @@ export function AppInner() { recipe: null, }); - // Store the active session ID for navigation persistence - const [activeSessionId, setActiveSessionId] = useState(null); - const { addExtension } = useConfig(); const { loadCurrentChat } = useAgent(); @@ -402,7 +348,6 @@ export function AppInner() { try { const loadedChat = await loadCurrentChat({ setAgentWaitingMessage, - setIsExtensionsLoading, }); setChat(loadedChat); } catch (e) { @@ -597,11 +542,21 @@ export function AppInner() { // Handle initial message from launcher useEffect(() => { - const handleSetInitialMessage = (_event: IpcRendererEvent, ...args: unknown[]) => { + const handleSetInitialMessage = async (_event: IpcRendererEvent, ...args: unknown[]) => { const initialMessage = args[0] as string; if (initialMessage) { console.log('Received initial message from launcher:', initialMessage); - navigate('/pair', { state: { initialMessage } }); + try { + const session = await createSession(getInitialWorkingDir(), {}); + navigate('/pair', { + state: { + initialMessage, + resumeSessionId: session.id, + }, + }); + } catch (error) { + console.error('Failed to create session for launcher message:', error); + } } }; window.electron.on('set-initial-message', handleSetInitialMessage); @@ -657,18 +612,8 @@ export function AppInner() { } > - } /> - - } - /> + } /> + } /> } /> ; +}; + export type ResumeAgentRequest = { load_model_and_extensions: boolean; session_id: string; }; +export type ResumeAgentResponse = { + extension_results?: Array | null; + session: Session; +}; + /** * Configuration for retry logic in recipe execution */ @@ -1255,9 +1270,11 @@ export type RestartAgentResponses = { /** * Agent restarted successfully */ - 200: unknown; + 200: RestartAgentResponse; }; +export type RestartAgentResponse2 = RestartAgentResponses[keyof RestartAgentResponses]; + export type ResumeAgentData = { body: ResumeAgentRequest; path?: never; @@ -1284,10 +1301,10 @@ export type ResumeAgentResponses = { /** * Agent started successfully */ - 200: Session; + 200: ResumeAgentResponse; }; -export type ResumeAgentResponse = ResumeAgentResponses[keyof ResumeAgentResponses]; +export type ResumeAgentResponse2 = ResumeAgentResponses[keyof ResumeAgentResponses]; export type StartAgentData = { body: StartAgentRequest; diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 8c4ed85e6266..5744aa33077b 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -131,11 +131,9 @@ function BaseChatContent({ const shouldStartAgent = searchParams.get('shouldStartAgent') === 'true'; if (initialMessage) { - // Submit the initial message (e.g., from fork) hasAutoSubmittedRef.current = true; handleSubmit(initialMessage); } else if (shouldStartAgent) { - // Trigger agent to continue with existing conversation hasAutoSubmittedRef.current = true; handleSubmit(''); } @@ -300,8 +298,7 @@ function BaseChatContent({ : recipe.prompt; } - const initialPrompt = - (initialMessage && !hasAutoSubmittedRef.current ? initialMessage : '') || recipePrompt; + const initialPrompt = recipePrompt; if (sessionLoadError) { return ( diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 6a7a52934499..e35e5d159699 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -97,7 +97,7 @@ interface ChatInputProps { initialPrompt?: string; toolCount: number; append?: (message: Message) => void; - isExtensionsLoading?: boolean; + onWorkingDirChange?: (newDir: string) => void; } export default function ChatInput({ @@ -122,7 +122,7 @@ export default function ChatInput({ initialPrompt, toolCount, append: _append, - isExtensionsLoading = false, + onWorkingDirChange, }: ChatInputProps) { const [_value, setValue] = useState(initialValue); const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback @@ -152,6 +152,7 @@ export default function ChatInput({ const [showEditRecipeModal, setShowEditRecipeModal] = useState(false); const [isFilePickerOpen, setIsFilePickerOpen] = useState(false); const [sessionWorkingDir, setSessionWorkingDir] = useState(null); + const [isRestartingAgent, setIsRestartingAgent] = useState(false); useEffect(() => { if (!sessionId) { @@ -1131,7 +1132,7 @@ export default function ChatInput({ isAnyDroppedFileLoading || isRecording || isTranscribing || - isExtensionsLoading; + isRestartingAgent; // Queue management functions - no storage persistence, only in-memory const handleRemoveQueuedMessage = (messageId: string) => { @@ -1374,16 +1375,16 @@ export default function ChatInput({

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

@@ -1409,6 +1410,15 @@ export default function ChatInput({ )}
)} + + {isRestartingAgent && !isRecording && !isTranscribing && ( +
+ + + Restarting agent... + +
+ )}
@@ -1528,7 +1538,14 @@ export default function ChatInput({ className="mr-0" sessionId={sessionId ?? undefined} workingDir={sessionWorkingDir ?? getInitialWorkingDir()} - onWorkingDirChange={(newDir) => setSessionWorkingDir(newDir)} + onWorkingDirChange={(newDir) => { + setSessionWorkingDir(newDir); + if (onWorkingDirChange) { + onWorkingDirChange(newDir); + } + }} + onRestartStart={() => setIsRestartingAgent(true)} + onRestartEnd={() => setIsRestartingAgent(false)} />
@@ -1574,7 +1591,11 @@ export default function ChatInput({
- + setIsRestartingAgent(true)} + onRestartEnd={() => setIsRestartingAgent(false)} + /> {sessionId && messages.length > 0 && ( <>
diff --git a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx index 91cabb33fc83..7f69415d321c 100644 --- a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx +++ b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx @@ -103,18 +103,18 @@ export function GroupedExtensionLoadingToast({
{formatExtensionErrorMessage(ext.error, 'Failed to add extension')}
- {ext.recoverHints && setView ? ( - - ) : ( +
+ {ext.recoverHints && setView && ( + + )} - )} +
)}
diff --git a/ui/desktop/src/components/Hub.tsx b/ui/desktop/src/components/Hub.tsx index 1c1865764050..251a10bfcd64 100644 --- a/ui/desktop/src/components/Hub.tsx +++ b/ui/desktop/src/components/Hub.tsx @@ -7,45 +7,61 @@ * Key Responsibilities: * - Displays SessionInsights to show session statistics and recent chats * - Provides a ChatInput for users to start new conversations - * - Navigates to Pair with the submitted message to start a new conversation - * - Ensures each submission from Hub always starts a fresh conversation + * - Creates a new session and navigates to Pair with the session ID + * - Shows loading state while session is being created * * Navigation Flow: - * Hub (input submission) → Pair (new conversation with the submitted message) + * Hub (input submission) → Create Session → Pair (with session ID and initial message) */ +import { useState } from 'react'; import { SessionInsights } from './sessions/SessionsInsights'; import ChatInput from './ChatInput'; import { ChatState } from '../types/chatState'; import 'react-toastify/dist/ReactToastify.css'; import { View, ViewOptions } from '../utils/navigationUtils'; -import { createSession } from '../sessions'; import { useConfig } from './ConfigContext'; +import { + getExtensionConfigsWithOverrides, + clearExtensionOverrides, +} from '../store/extensionOverrides'; import { getInitialWorkingDir } from '../utils/workingDir'; +import { createSession } from '../sessions'; +import LoadingGoose from './LoadingGoose'; export default function Hub({ setView, - isExtensionsLoading, }: { setView: (view: View, viewOptions?: ViewOptions) => void; - isExtensionsLoading: boolean; }) { const { extensionsList } = useConfig(); + const [workingDir, setWorkingDir] = useState(getInitialWorkingDir()); + const [isCreatingSession, setIsCreatingSession] = useState(false); const handleSubmit = async (e: React.FormEvent) => { const customEvent = e as unknown as CustomEvent; const combinedTextFromInput = customEvent.detail?.value || ''; - if (combinedTextFromInput.trim()) { - const session = await createSession(getInitialWorkingDir(), { - allExtensions: extensionsList, - }); + if (combinedTextFromInput.trim() && !isCreatingSession) { + const extensionConfigs = getExtensionConfigsWithOverrides(extensionsList); + clearExtensionOverrides(); + setIsCreatingSession(true); + + try { + const session = await createSession(workingDir, { + extensionConfigs, + allExtensions: extensionConfigs ? undefined : extensionsList, + }); - setView('pair', { - disableAnimation: true, - initialMessage: combinedTextFromInput, - resumeSessionId: session.id, - }); + setView('pair', { + disableAnimation: true, + resumeSessionId: session.id, + initialMessage: combinedTextFromInput, + }); + } catch (error) { + console.error('Failed to create session:', error); + setIsCreatingSession(false); + } e.preventDefault(); } @@ -53,14 +69,19 @@ export default function Hub({ return (
-
+
+ {isCreatingSession && ( +
+ +
+ )}
{}} initialValue="" setView={setView} @@ -72,8 +93,8 @@ export default function Hub({ messages={[]} disableAnimation={false} sessionCosts={undefined} - isExtensionsLoading={isExtensionsLoading} toolCount={0} + onWorkingDirChange={setWorkingDir} />
); diff --git a/ui/desktop/src/components/LoadingGoose.tsx b/ui/desktop/src/components/LoadingGoose.tsx index 56cddb27aa61..92f8b81375ea 100644 --- a/ui/desktop/src/components/LoadingGoose.tsx +++ b/ui/desktop/src/components/LoadingGoose.tsx @@ -15,6 +15,7 @@ const STATE_MESSAGES: Record = { [ChatState.WaitingForUserInput]: 'goose is waiting…', [ChatState.Compacting]: 'goose is compacting the conversation...', [ChatState.Idle]: 'goose is working on it…', + [ChatState.RestartingAgent]: 'restarting agent...', }; const STATE_ICONS: Record = { @@ -26,6 +27,7 @@ const STATE_ICONS: Record = { ), [ChatState.Compacting]: , [ChatState.Idle]: , + [ChatState.RestartingAgent]: , }; const LoadingGoose = ({ message, chatState = ChatState.Idle }: LoadingGooseProps) => { diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx index 27bcbe2d8651..64e100ebd831 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx @@ -16,15 +16,22 @@ import { interface BottomMenuExtensionSelectionProps { sessionId: string | null; + onRestartStart?: () => void; + onRestartEnd?: () => void; } -export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionSelectionProps) => { +export const BottomMenuExtensionSelection = ({ + sessionId, + onRestartStart, + onRestartEnd, +}: BottomMenuExtensionSelectionProps) => { const [searchQuery, setSearchQuery] = useState(''); const [isOpen, setIsOpen] = useState(false); const [sessionExtensions, setSessionExtensions] = useState([]); const [hubUpdateTrigger, setHubUpdateTrigger] = useState(0); const [isTransitioning, setIsTransitioning] = useState(false); const [pendingSort, setPendingSort] = useState(false); + const [togglingExtension, setTogglingExtension] = useState(null); const sortTimeoutRef = useRef | null>(null); const { extensionsList: allExtensions } = useConfig(); const isHubView = !sessionId; @@ -63,17 +70,18 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS const handleToggle = useCallback( async (extensionConfig: FixedExtensionEntry) => { - // Start transition animation + if (togglingExtension === extensionConfig.name) { + return; + } + setIsTransitioning(true); + setTogglingExtension(extensionConfig.name); if (isHubView) { const currentState = getExtensionOverride(extensionConfig.name) ?? extensionConfig.enabled; setExtensionOverride(extensionConfig.name, !currentState); - - // Mark that we need to re-sort after delay setPendingSort(true); - // Clear any existing timeout if (sortTimeoutRef.current) { clearTimeout(sortTimeoutRef.current); } @@ -83,6 +91,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS setHubUpdateTrigger((prev) => prev + 1); setPendingSort(false); setIsTransitioning(false); + setTogglingExtension(null); }, 800); toastService.success({ @@ -94,6 +103,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS if (!sessionId) { setIsTransitioning(false); + setTogglingExtension(null); toastService.error({ title: 'Extension Toggle Error', msg: 'No active session found. Please start a chat session first.', @@ -102,6 +112,8 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS return; } + onRestartStart?.(); + try { if (extensionConfig.enabled) { await removeFromAgent(extensionConfig.name, sessionId, true); @@ -125,18 +137,17 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS } setPendingSort(false); setIsTransitioning(false); + setTogglingExtension(null); + onRestartEnd?.(); }, 800); - } catch (error) { + } catch { setIsTransitioning(false); setPendingSort(false); - toastService.error({ - title: 'Extension Error', - msg: `Failed to ${extensionConfig.enabled ? 'disable' : 'enable'} ${extensionConfig.name}`, - traceback: error instanceof Error ? error.message : String(error), - }); + setTogglingExtension(null); + onRestartEnd?.(); } }, - [sessionId, isHubView] + [sessionId, isHubView, togglingExtension, onRestartStart, onRestartEnd] ); // Merge all available extensions with session-specific or hub override state @@ -201,6 +212,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS } setIsTransitioning(false); setPendingSort(false); + setTogglingExtension(null); } }} > @@ -213,7 +225,14 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS {activeCount} - + { + e.preventDefault(); + }} + >
) : ( - sortedExtensions.map((ext) => ( -
handleToggle(ext)} - title={ext.description || ext.name} - > -
{getFriendlyTitle(ext)}
-
e.stopPropagation()}> - handleToggle(ext)} - variant="mono" - /> + sortedExtensions.map((ext) => { + const isToggling = togglingExtension === ext.name; + return ( +
!isToggling && handleToggle(ext)} + title={ext.description || ext.name} + > +
+ {getFriendlyTitle(ext)} +
+
e.stopPropagation()}> + handleToggle(ext)} + variant="mono" + disabled={isToggling} + /> +
-
- )) + ); + }) )}
diff --git a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx index be505bcb2dfa..26c8dc399eae 100644 --- a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx +++ b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx @@ -9,6 +9,8 @@ interface DirSwitcherProps { sessionId: string | undefined; workingDir: string; onWorkingDirChange?: (newDir: string) => void; + onRestartStart?: () => void; + onRestartEnd?: () => void; } export const DirSwitcher: React.FC = ({ @@ -16,6 +18,8 @@ export const DirSwitcher: React.FC = ({ sessionId, workingDir, onWorkingDirChange, + onRestartStart, + onRestartEnd, }) => { const [isTooltipOpen, setIsTooltipOpen] = useState(false); const [isDirectoryChooserOpen, setIsDirectoryChooserOpen] = useState(false); @@ -40,14 +44,18 @@ export const DirSwitcher: React.FC = ({ window.electron.addRecentDir(newDir); if (sessionId) { + onWorkingDirChange?.(newDir); + onRestartStart?.(); + try { await updateWorkingDir({ body: { session_id: sessionId, working_dir: newDir }, }); - onWorkingDirChange?.(newDir); } catch (error) { console.error('[DirSwitcher] Failed to update working directory:', error); toast.error('Failed to update working directory'); + } finally { + onRestartEnd?.(); } } else { onWorkingDirChange?.(newDir); diff --git a/ui/desktop/src/components/extensions/ExtensionsView.tsx b/ui/desktop/src/components/extensions/ExtensionsView.tsx index 25934351b02c..0bbb3ff558b1 100644 --- a/ui/desktop/src/components/extensions/ExtensionsView.tsx +++ b/ui/desktop/src/components/extensions/ExtensionsView.tsx @@ -105,7 +105,7 @@ export default function ExtensionsView({

Extensions enabled here are used as the default for new chats. You can also toggle - extensions using the extensions menu during chat. + extensions using the menu during chat.

{/* Action Buttons */} diff --git a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx index 9293edcc18bc..ef3b577ce9a3 100644 --- a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx @@ -46,25 +46,12 @@ export default function ExtensionsSection({ const [showEnvVarsStateVar, setShowEnvVarsStateVar] = useState( showEnvVars ); - const [pendingActivationExtensions, setPendingActivationExtensions] = useState>( - new Set() - ); - // Update deep link state when props change useEffect(() => { setDeepLinkConfigStateVar(deepLinkConfig); setShowEnvVarsStateVar(showEnvVars); - - if (deepLinkConfig && !showEnvVars) { - setPendingActivationExtensions((prev) => { - const updated = new Set(prev); - updated.add(deepLinkConfig.name); - return updated; - }); - } }, [deepLinkConfig, showEnvVars]); - // Process extensions from context - this automatically updates when extensionsList changes const extensions = useMemo(() => { if (extensionsList.length === 0) return []; @@ -108,12 +95,6 @@ export default function ExtensionsSection({ addToConfig: addExtension, }); - setPendingActivationExtensions((prev) => { - const updated = new Set(prev); - updated.delete(extensionConfig.name); - return updated; - }); - await fetchExtensions(); return true; }; @@ -133,21 +114,8 @@ export default function ExtensionsSection({ addToConfig: addExtension, extensionConfig: extensionConfig, }); - setPendingActivationExtensions((prev) => { - const updated = new Set(prev); - updated.delete(extensionConfig.name); - return updated; - }); } catch (error) { - console.error('Failed to activate extension:', error); - // If activation fails, mark as pending if it's enabled in config - if (formData.enabled) { - setPendingActivationExtensions((prev) => { - const updated = new Set(prev); - updated.add(extensionConfig.name); - return updated; - }); - } + console.error('Failed to add extension:', error); } finally { await fetchExtensions(); if (onModalClose) { @@ -220,7 +188,6 @@ export default function ExtensionsSection({ onConfigure={handleConfigureClick} disableConfiguration={disableConfiguration} searchTerm={searchTerm} - pendingActivationExtensions={pendingActivationExtensions} /> {!hideButtons && ( diff --git a/ui/desktop/src/components/settings/extensions/extension-manager.test.ts b/ui/desktop/src/components/settings/extensions/extension-manager.test.ts deleted file mode 100644 index 0151ebca3658..000000000000 --- a/ui/desktop/src/components/settings/extensions/extension-manager.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { addToAgentOnStartup, updateExtension, toggleExtension } from './extension-manager'; -import * as agentApi from './agent-api'; -import * as toasts from '../../../toasts'; - -// Mock dependencies -vi.mock('./agent-api'); -vi.mock('../../../toasts'); - -const mockAddToAgent = vi.mocked(agentApi.addToAgent); -const mockRemoveFromAgent = vi.mocked(agentApi.removeFromAgent); -const mockSanitizeName = vi.mocked(agentApi.sanitizeName); -const mockToastService = vi.mocked(toasts.toastService); - -describe('Extension Manager', () => { - const mockAddToConfig = vi.fn(); - const mockRemoveFromConfig = vi.fn(); - - const mockExtensionConfig = { - type: 'stdio' as const, - name: 'test-extension', - description: 'test-extension', - cmd: 'python', - args: ['script.py'], - timeout: 300, - }; - - beforeEach(() => { - vi.clearAllMocks(); - mockSanitizeName.mockImplementation((name: string) => name.toLowerCase()); - mockAddToConfig.mockResolvedValue(undefined); - mockRemoveFromConfig.mockResolvedValue(undefined); - }); - - describe('addToAgentOnStartup', () => { - it('should successfully add extension on startup', async () => { - mockAddToAgent.mockResolvedValue(undefined); - - await addToAgentOnStartup({ - sessionId: 'test-session', - extensionConfig: mockExtensionConfig, - }); - - expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true); - }); - - it('should successfully add extension on startup with custom toast options', async () => { - mockAddToAgent.mockResolvedValue(undefined); - - await addToAgentOnStartup({ - sessionId: 'test-session', - extensionConfig: mockExtensionConfig, - }); - - expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true); - }); - - it('should retry on 428 errors', async () => { - const error428 = new Error('428 Precondition Required'); - mockAddToAgent - .mockRejectedValueOnce(error428) - .mockRejectedValueOnce(error428) - .mockResolvedValue(undefined); - - await addToAgentOnStartup({ - sessionId: 'test-session', - extensionConfig: mockExtensionConfig, - }); - - expect(mockAddToAgent).toHaveBeenCalledTimes(3); - }); - - it('should throw error after max retries', async () => { - const error428 = new Error('428 Precondition Required'); - mockAddToAgent.mockRejectedValue(error428); - - await expect( - addToAgentOnStartup({ - sessionId: 'test-session', - extensionConfig: mockExtensionConfig, - }) - ).rejects.toThrow('428 Precondition Required'); - - expect(mockAddToAgent).toHaveBeenCalledTimes(4); // Initial + 3 retries - }); - }); - - describe('updateExtension', () => { - it('should update extension without name change', async () => { - mockAddToAgent.mockResolvedValue(undefined); - mockAddToConfig.mockResolvedValue(undefined); - mockToastService.success = vi.fn(); - - await updateExtension({ - enabled: true, - addToConfig: mockAddToConfig, - sessionId: 'test-session', - removeFromConfig: mockRemoveFromConfig, - extensionConfig: mockExtensionConfig, - originalName: 'test-extension', - }); - - expect(mockAddToConfig).toHaveBeenCalledWith( - 'test-extension', - { ...mockExtensionConfig, name: 'test-extension' }, - true - ); - expect(mockToastService.success).toHaveBeenCalledWith({ - title: 'Update extension', - msg: 'Successfully updated test-extension extension', - }); - }); - - it('should handle name change by removing old and adding new', async () => { - mockAddToAgent.mockResolvedValue(undefined); - mockRemoveFromAgent.mockResolvedValue(undefined); - mockRemoveFromConfig.mockResolvedValue(undefined); - mockAddToConfig.mockResolvedValue(undefined); - mockToastService.success = vi.fn(); - - await updateExtension({ - enabled: true, - addToConfig: mockAddToConfig, - sessionId: 'test-session', - removeFromConfig: mockRemoveFromConfig, - extensionConfig: { ...mockExtensionConfig, name: 'new-extension' }, - originalName: 'old-extension', - }); - - expect(mockRemoveFromConfig).toHaveBeenCalledWith('old-extension'); - expect(mockAddToAgent).toHaveBeenCalledWith( - { ...mockExtensionConfig, name: 'new-extension' }, - 'test-session', - false - ); - expect(mockAddToConfig).toHaveBeenCalledWith( - 'new-extension', - { ...mockExtensionConfig, name: 'new-extension' }, - true - ); - }); - - it('should update disabled extension without calling agent', async () => { - mockAddToConfig.mockResolvedValue(undefined); - mockToastService.success = vi.fn(); - - await updateExtension({ - enabled: false, - addToConfig: mockAddToConfig, - sessionId: 'test-session', - removeFromConfig: mockRemoveFromConfig, - extensionConfig: mockExtensionConfig, - originalName: 'test-extension', - }); - - expect(mockAddToAgent).not.toHaveBeenCalled(); - expect(mockAddToConfig).toHaveBeenCalledWith( - 'test-extension', - { ...mockExtensionConfig, name: 'test-extension' }, - false - ); - expect(mockToastService.success).toHaveBeenCalledWith({ - title: 'Update extension', - msg: 'Successfully updated test-extension extension', - }); - }); - }); - - describe('toggleExtension', () => { - it('should toggle extension on successfully', async () => { - mockAddToAgent.mockResolvedValue(undefined); - mockAddToConfig.mockResolvedValue(undefined); - - await toggleExtension({ - toggle: 'toggleOn', - extensionConfig: mockExtensionConfig, - addToConfig: mockAddToConfig, - sessionId: 'test-session', - }); - - expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true); - expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, true); - }); - - it('should toggle extension off successfully', async () => { - mockRemoveFromAgent.mockResolvedValue(undefined); - mockAddToConfig.mockResolvedValue(undefined); - - await toggleExtension({ - toggle: 'toggleOff', - extensionConfig: mockExtensionConfig, - addToConfig: mockAddToConfig, - sessionId: 'test-session', - }); - - expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', 'test-session', true); - expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false); - }); - - it('should rollback on agent failure when toggling on', async () => { - const agentError = new Error('Agent failed'); - mockAddToAgent.mockRejectedValue(agentError); - mockAddToConfig.mockResolvedValue(undefined); - - await expect( - toggleExtension({ - toggle: 'toggleOn', - extensionConfig: mockExtensionConfig, - addToConfig: mockAddToConfig, - sessionId: 'test-session', - }) - ).rejects.toThrow('Agent failed'); - - expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true); - // addToConfig is called during the rollback (toggleOff) - expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false); - }); - - it('should remove from agent if config update fails when toggling on', async () => { - const configError = new Error('Config failed'); - mockAddToAgent.mockResolvedValue(undefined); - mockAddToConfig.mockRejectedValue(configError); - - await expect( - toggleExtension({ - toggle: 'toggleOn', - extensionConfig: mockExtensionConfig, - addToConfig: mockAddToConfig, - sessionId: 'test-session', - }) - ).rejects.toThrow('Config failed'); - - expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true); - expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, true); - expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', 'test-session', true); - }); - - it('should update config even if agent removal fails when toggling off', async () => { - const agentError = new Error('Agent removal failed'); - mockRemoveFromAgent.mockRejectedValue(agentError); - mockAddToConfig.mockResolvedValue(undefined); - - await expect( - toggleExtension({ - toggle: 'toggleOff', - extensionConfig: mockExtensionConfig, - addToConfig: mockAddToConfig, - sessionId: 'test-session', - }) - ).rejects.toThrow('Agent removal failed'); - - expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false); - }); - }); -}); diff --git a/ui/desktop/src/components/settings/extensions/extension-manager.ts b/ui/desktop/src/components/settings/extensions/extension-manager.ts index e30b971b3dc2..bf5c501eb4c2 100644 --- a/ui/desktop/src/components/settings/extensions/extension-manager.ts +++ b/ui/desktop/src/components/settings/extensions/extension-manager.ts @@ -1,6 +1,5 @@ import type { ExtensionConfig } from '../../../api/types.gen'; -import { toastService, ToastServiceOptions } from '../../../toasts'; -import { addToAgent, removeFromAgent, sanitizeName } from './agent-api'; +import { toastService } from '../../../toasts'; import { trackExtensionAdded, trackExtensionEnabled, @@ -13,369 +12,30 @@ function isBuiltinExtension(config: ExtensionConfig): boolean { return config.type === 'builtin'; } -interface ActivateExtensionProps { - addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; - extensionConfig: ExtensionConfig; - sessionId: string; -} - -type ExtensionError = { - message?: string; - code?: number; - name?: string; - stack?: string; -}; - -type RetryOptions = { - retries?: number; - delayMs?: number; - shouldRetry?: (error: ExtensionError, attempt: number) => boolean; - backoffFactor?: number; // multiplier for exponential backoff -}; - -async function retryWithBackoff(fn: () => Promise, options: RetryOptions = {}): Promise { - const { retries = 3, delayMs = 1000, backoffFactor = 1.5, shouldRetry = () => true } = options; - - let attempt = 0; - let lastError: ExtensionError = new Error('Unknown error'); - - while (attempt <= retries) { - try { - return await fn(); - } catch (err) { - lastError = err as ExtensionError; - attempt++; - - if (attempt > retries || !shouldRetry(lastError, attempt)) { - break; - } - - const waitTime = delayMs * Math.pow(backoffFactor, attempt - 1); - console.warn(`Retry attempt ${attempt} failed. Retrying in ${waitTime}ms...`, err); - await new Promise((res) => setTimeout(res, waitTime)); - } - } - - throw lastError; -} - -/** - * Activates an extension by adding it to both the config system and the API. - * @param props The extension activation properties - * @returns Promise that resolves when activation is complete - */ -export async function activateExtension({ - addToConfig, - extensionConfig, - sessionId, -}: ActivateExtensionProps): Promise { - const isBuiltin = isBuiltinExtension(extensionConfig); - - try { - // AddToAgent - await addToAgent(extensionConfig, sessionId, true); - } catch (error) { - console.error('Failed to add extension to agent:', error); - await addToConfig(extensionConfig.name, extensionConfig, false); - trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin); - throw error; - } - - try { - await addToConfig(extensionConfig.name, extensionConfig, true); - trackExtensionAdded(extensionConfig.name, true, undefined, isBuiltin); - } catch (error) { - console.error('Failed to add extension to config:', error); - // remove from Agent - try { - await removeFromAgent(extensionConfig.name, sessionId, true); - } catch (removeError) { - console.error('Failed to remove extension from agent after config failure:', removeError); - } - trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin); - // Rethrow the error to inform the caller - throw error; - } -} - -interface AddToAgentOnStartupProps { - extensionConfig: ExtensionConfig; - toastOptions?: ToastServiceOptions; - sessionId: string; -} - -/** - * Adds an extension to the agent during application startup with retry logic - * - * TODO(Douwe): Delete this after basecamp lands - */ -export async function addToAgentOnStartup({ - extensionConfig, - sessionId, - toastOptions, -}: AddToAgentOnStartupProps): Promise { - const showToast = !toastOptions?.silent; - - // Errors are caught by the grouped notification in providerUtils.ts - // Individual error toasts are suppressed during startup (showToast=false) - await retryWithBackoff(() => addToAgent(extensionConfig, sessionId, showToast), { - retries: 3, - delayMs: 1000, - shouldRetry: (error: ExtensionError) => - !!error.message && - (error.message.includes('428') || - error.message.includes('Precondition Required') || - error.message.includes('Agent is not initialized')), - }); -} - -interface UpdateExtensionProps { - enabled: boolean; - addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; - removeFromConfig: (name: string) => Promise; - extensionConfig: ExtensionConfig; - originalName?: string; - sessionId?: string; -} - -export async function updateExtension({ - enabled, - addToConfig, - removeFromConfig, - extensionConfig, - originalName, - sessionId, -}: UpdateExtensionProps) { - const sanitizedNewName = sanitizeName(extensionConfig.name); - const sanitizedOriginalName = originalName ? sanitizeName(originalName) : undefined; - const nameChanged = sanitizedOriginalName && sanitizedOriginalName !== sanitizedNewName; - - if (nameChanged) { - if (sessionId) { - try { - await removeFromAgent(originalName!, sessionId, false); - } catch (error) { - console.error('Failed to remove old extension from agent during rename:', error); - // Continue with the process even if agent removal fails - } - } - - try { - await removeFromConfig(originalName!); - } catch (error) { - console.error('Failed to remove old extension from config during rename:', error); - throw error; - } - - const sanitizedExtensionConfig = { - ...extensionConfig, - name: sanitizedNewName, - }; - - if (enabled && sessionId) { - try { - await addToAgent(sanitizedExtensionConfig, sessionId, false); - } catch (error) { - console.error('[updateExtension]: Failed to add renamed extension to agent:', error); - throw error; - } - } - - try { - await addToConfig(sanitizedNewName, sanitizedExtensionConfig, enabled); - } catch (error) { - console.error('[updateExtension]: Failed to add renamed extension to config:', error); - throw error; - } - - toastService.configure({ silent: false }); - toastService.success({ - title: `Update extension`, - msg: `Successfully updated ${sanitizedNewName} extension`, - }); - } else { - const sanitizedExtensionConfig = { - ...extensionConfig, - name: sanitizedNewName, - }; - - if (enabled && sessionId) { - try { - await addToAgent(sanitizedExtensionConfig, sessionId, false); - } catch (error) { - console.error('[updateExtension]: Failed to add extension to agent during update:', error); - throw error; - } - - // Then add to config - try { - await addToConfig(sanitizedNewName, sanitizedExtensionConfig, enabled); - } catch (error) { - console.error('[updateExtension]: Failed to update extension in config:', error); - throw error; - } - - toastService.success({ - title: `Update extension`, - msg: `Successfully updated ${sanitizedNewName} extension`, - }); - } else { - try { - await addToConfig(sanitizedNewName, sanitizedExtensionConfig, enabled); - } catch (error) { - console.error('[updateExtension]: Failed to update extension in config:', error); - throw error; - } - - toastService.success({ - title: `Update extension`, - msg: `Successfully updated ${sanitizedNewName} extension`, - }); - } - } -} - -interface ToggleExtensionProps { - toggle: 'toggleOn' | 'toggleOff'; - extensionConfig: ExtensionConfig; - addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; - toastOptions?: ToastServiceOptions; - sessionId: string; -} - -/** - * Toggles an extension between enabled and disabled states - */ -export async function toggleExtension({ - toggle, - extensionConfig, - addToConfig, - toastOptions = {}, - sessionId, -}: ToggleExtensionProps) { - const isBuiltin = isBuiltinExtension(extensionConfig); - - // disabled to enabled - if (toggle == 'toggleOn') { - try { - // add to agent with toast options - await addToAgent(extensionConfig, sessionId, !toastOptions?.silent); - } catch (error) { - console.error('Error adding extension to agent. Attempting to toggle back off.'); - trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin); - try { - await toggleExtension({ - toggle: 'toggleOff', - extensionConfig, - addToConfig, - toastOptions: { silent: true }, // otherwise we will see a toast for removing something that was never added - sessionId, - }); - } catch (toggleError) { - console.error('Failed to toggle extension off after agent error:', toggleError); - } - throw error; - } - - // update the config - try { - await addToConfig(extensionConfig.name, extensionConfig, true); - trackExtensionEnabled(extensionConfig.name, true, undefined, isBuiltin); - } catch (error) { - console.error('Failed to update config after enabling extension:', error); - trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin); - // remove from agent - try { - await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent); - } catch (removeError) { - console.error('Failed to remove extension from agent after config failure:', removeError); - } - throw error; - } - } else if (toggle == 'toggleOff') { - // enabled to disabled - let agentRemoveError = null; - try { - await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent); - } catch (error) { - // note there was an error, but attempt to remove from config anyway - console.error('Error removing extension from agent', extensionConfig.name, error); - agentRemoveError = error; - } - - // update the config - try { - await addToConfig(extensionConfig.name, extensionConfig, false); - if (agentRemoveError) { - trackExtensionDisabled( - extensionConfig.name, - false, - getErrorType(agentRemoveError), - isBuiltin - ); - } else { - trackExtensionDisabled(extensionConfig.name, true, undefined, isBuiltin); - } - } catch (error) { - console.error('Error removing extension from config', extensionConfig.name, 'Error:', error); - trackExtensionDisabled(extensionConfig.name, false, getErrorType(error), isBuiltin); - throw error; - } - - // If we had an error removing from agent but succeeded updating config, still throw the original error - if (agentRemoveError) { - throw agentRemoveError; - } - } -} - interface DeleteExtensionProps { name: string; removeFromConfig: (name: string) => Promise; - sessionId?: string; extensionConfig?: ExtensionConfig; } /** - * Deletes an extension completely from both agent and config + * Deletes an extension from config (will no longer be loaded in new sessions) */ export async function deleteExtension({ name, removeFromConfig, - sessionId, extensionConfig, }: DeleteExtensionProps) { const isBuiltin = extensionConfig ? isBuiltinExtension(extensionConfig) : false; - let agentRemoveError = null; - if (sessionId) { - try { - await removeFromAgent(name, sessionId, true); - } catch (error) { - console.error('Failed to remove extension from agent during deletion:', error); - agentRemoveError = error; - } - } - try { await removeFromConfig(name); - if (agentRemoveError) { - trackExtensionDeleted(name, false, getErrorType(agentRemoveError), isBuiltin); - } else { - trackExtensionDeleted(name, true, undefined, isBuiltin); - } + trackExtensionDeleted(name, true, undefined, isBuiltin); } catch (error) { - console.error( - 'Failed to remove extension from config after removing from agent. Error:', - error - ); + console.error('Failed to remove extension from config:', error); trackExtensionDeleted(name, false, getErrorType(error), isBuiltin); throw error; } - - if (agentRemoveError) { - throw agentRemoveError; - } } interface ToggleExtensionDefaultProps { @@ -401,7 +61,7 @@ export async function toggleExtensionDefault({ } toastService.success({ title: extensionConfig.name, - msg: enabled ? 'Extension enabled as default' : 'Extension disabled as default', + msg: enabled ? 'Extension enabled in defaults' : 'Extension removed from defaults', }); } catch (error) { console.error('Failed to update extension default in config:', error); diff --git a/ui/desktop/src/components/settings/extensions/index.ts b/ui/desktop/src/components/settings/extensions/index.ts index f0fdfbd7986d..67d0dc161df0 100644 --- a/ui/desktop/src/components/settings/extensions/index.ts +++ b/ui/desktop/src/components/settings/extensions/index.ts @@ -1,22 +1,13 @@ -// Export public API export { DEFAULT_EXTENSION_TIMEOUT, nameToKey } from './utils'; -// Export extension management functions export { - activateExtension, activateExtensionDefault, - addToAgentOnStartup, - updateExtension, - toggleExtension, toggleExtensionDefault, deleteExtension, } from './extension-manager'; -// Export built-in extension functions export { syncBundledExtensions, initializeBundledExtensions } from './bundled-extensions'; -// Export deeplink handling export { addExtensionFromDeepLink } from './deeplink'; -// Export agent API functions -export { addToAgent as AddToAgent, removeFromAgent as RemoveFromAgent } from './agent-api'; +export { addToAgent, removeFromAgent } from './agent-api'; diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx index f677260332eb..92663222a58a 100644 --- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx +++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx @@ -11,7 +11,6 @@ interface ExtensionItemProps { onToggle: (extension: FixedExtensionEntry) => Promise | void; onConfigure?: (extension: FixedExtensionEntry) => void; isStatic?: boolean; // to not allow users to edit configuration - isPendingActivation?: boolean; } export default function ExtensionItem({ @@ -19,7 +18,6 @@ export default function ExtensionItem({ onToggle, onConfigure, isStatic, - isPendingActivation = false, }: ExtensionItemProps) { // Add local state to track the visual toggle state const [visuallyEnabled, setVisuallyEnabled] = useState(extension.enabled); @@ -81,17 +79,7 @@ export default function ExtensionItem({ onClick={() => handleToggle(extension)} > - - {getFriendlyTitle(extension)} - {isPendingActivation && ( - - Pending - - )} - + {getFriendlyTitle(extension)} e.stopPropagation()}>
diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx index f29cde546a85..e15e86007892 100644 --- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx +++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx @@ -11,7 +11,6 @@ interface ExtensionListProps { isStatic?: boolean; disableConfiguration?: boolean; searchTerm?: string; - pendingActivationExtensions?: Set; } export default function ExtensionList({ @@ -21,7 +20,6 @@ export default function ExtensionList({ isStatic, disableConfiguration: _disableConfiguration, searchTerm = '', - pendingActivationExtensions = new Set(), }: ExtensionListProps) { const matchesSearch = (extension: FixedExtensionEntry): boolean => { if (!searchTerm) return true; @@ -65,7 +63,6 @@ export default function ExtensionList({ onToggle={onToggle} onConfigure={onConfigure} isStatic={isStatic} - isPendingActivation={pendingActivationExtensions.has(extension.name)} /> ))}
diff --git a/ui/desktop/src/hooks/useAgent.ts b/ui/desktop/src/hooks/useAgent.ts index 50c7bd72af7c..47b77513d18d 100644 --- a/ui/desktop/src/hooks/useAgent.ts +++ b/ui/desktop/src/hooks/useAgent.ts @@ -28,7 +28,6 @@ export interface InitializationContext { recipe?: Recipe; resumeSessionId?: string; setAgentWaitingMessage: (msg: string | null) => void; - setIsExtensionsLoading?: (isLoading: boolean) => void; } interface UseAgentReturn { @@ -114,15 +113,15 @@ export function useAgent(): UseAgentReturn { // Fall through to create new session if (agentResponse?.data) { - const agentSession = agentResponse.data; - const messages = agentSession.conversation || []; + const agentSession = agentResponse.data.session; + const messages = agentSession?.conversation || []; return { - sessionId: agentSession.id, - name: agentSession.recipe?.title || agentSession.name, + sessionId: agentSession?.id || '', + name: agentSession?.recipe?.title || agentSession?.name || '', messageHistoryIndex: 0, messages, - recipe: agentSession.recipe, - recipeParameterValues: agentSession.user_recipe_values || null, + recipe: agentSession?.recipe, + recipeParameterValues: agentSession?.user_recipe_values || null, }; } } @@ -196,7 +195,10 @@ export function useAgent(): UseAgentReturn { } } - const agentSession = agentResponse.data; + // Handle different response types: resumeAgent returns { session, extension_results }, startAgent returns Session directly + const responseData = agentResponse.data; + const agentSession = + responseData && 'session' in responseData ? responseData.session : responseData; if (!agentSession) { throw Error('Failed to get session info'); } @@ -231,7 +233,6 @@ export function useAgent(): UseAgentReturn { await initializeSystem(agentSession.id, provider as string, model as string, { getExtensions, addExtension, - setIsExtensionsLoading: initContext.setIsExtensionsLoading, recipeParameters: agentSession.user_recipe_values, recipe: recipeForInit, }); diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index 01555361b53e..6e4efd6d5b87 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -20,6 +20,7 @@ import { NotificationEvent, } from '../types/message'; import { errorMessage } from '../utils/conversionUtils'; +import { showExtensionLoadResults } from '../utils/extensionErrorUtils'; const resultsCache = new Map(); @@ -221,6 +222,7 @@ export function useChatStream({ accumulatedTotalTokens: cached.session?.accumulated_total_tokens ?? 0, }); setChatState(ChatState.Idle); + onSessionLoaded?.(); return; } @@ -246,16 +248,20 @@ export function useChatStream({ return; } - const session = response.data; - setSession(session); - updateMessages(session?.conversation || []); + const resumeData = response.data; + const loadedSession = resumeData?.session; + const extensionResults = resumeData?.extension_results; + + showExtensionLoadResults(extensionResults); + setSession(loadedSession); + updateMessages(loadedSession?.conversation || []); setTokenState({ - inputTokens: session?.input_tokens ?? 0, - outputTokens: session?.output_tokens ?? 0, - totalTokens: session?.total_tokens ?? 0, - accumulatedInputTokens: session?.accumulated_input_tokens ?? 0, - accumulatedOutputTokens: session?.accumulated_output_tokens ?? 0, - accumulatedTotalTokens: session?.accumulated_total_tokens ?? 0, + inputTokens: loadedSession?.input_tokens ?? 0, + outputTokens: loadedSession?.output_tokens ?? 0, + totalTokens: loadedSession?.total_tokens ?? 0, + accumulatedInputTokens: loadedSession?.accumulated_input_tokens ?? 0, + accumulatedOutputTokens: loadedSession?.accumulated_output_tokens ?? 0, + accumulatedTotalTokens: loadedSession?.accumulated_total_tokens ?? 0, }); setChatState(ChatState.Idle); onSessionLoaded?.(); diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index b8fd7894be48..1efec4d44c0d 100644 --- a/ui/desktop/src/sessions.ts +++ b/ui/desktop/src/sessions.ts @@ -1,4 +1,4 @@ -import { Session, startAgent, restartAgent, ExtensionConfig } from './api'; +import { Session, startAgent, ExtensionConfig } from './api'; import type { setViewType } from './hooks/useNavigation'; import { getExtensionConfigsWithOverrides, @@ -19,6 +19,7 @@ export async function createSession( options?: { recipeId?: string; recipeDeeplink?: string; + extensionConfigs?: ExtensionConfig[]; allExtensions?: FixedExtensionEntry[]; } ): Promise { @@ -37,14 +38,16 @@ export async function createSession( body.recipe_deeplink = options.recipeDeeplink; } - // Get extension configs with any overrides applied - if (options?.allExtensions && hasExtensionOverrides()) { + if (options?.extensionConfigs && options.extensionConfigs.length > 0) { + body.extension_overrides = options.extensionConfigs; + } else if (options?.allExtensions) { const extensionConfigs = getExtensionConfigsWithOverrides(options.allExtensions); if (extensionConfigs.length > 0) { body.extension_overrides = extensionConfigs; } - // Clear the overrides after using them - clearExtensionOverrides(); + if (hasExtensionOverrides()) { + clearExtensionOverrides(); + } } const newAgent = await startAgent({ @@ -52,14 +55,7 @@ export async function createSession( throwOnError: true, }); - const session = newAgent.data; - - // Restart agent to ensure it picks up the session's working dir - await restartAgent({ - body: { session_id: session.id }, - }); - - return session; + return newAgent.data; } export async function startNewSession( diff --git a/ui/desktop/src/types/chatState.ts b/ui/desktop/src/types/chatState.ts index 067aee4f7b0b..46ec6c36853a 100644 --- a/ui/desktop/src/types/chatState.ts +++ b/ui/desktop/src/types/chatState.ts @@ -5,4 +5,5 @@ export enum ChatState { WaitingForUserInput = 'waitingForUserInput', Compacting = 'compacting', LoadingConversation = 'loadingConversation', + RestartingAgent = 'restartingAgent', } diff --git a/ui/desktop/src/utils/extensionErrorUtils.ts b/ui/desktop/src/utils/extensionErrorUtils.ts index e707c6c0e757..17b777750b22 100644 --- a/ui/desktop/src/utils/extensionErrorUtils.ts +++ b/ui/desktop/src/utils/extensionErrorUtils.ts @@ -2,6 +2,9 @@ * Shared constants and utilities for extension error handling */ +import { ExtensionLoadResult } from '../api/types.gen'; +import { toastService, ExtensionLoadingStatus } from '../toasts'; + export const MAX_ERROR_MESSAGE_LENGTH = 70; /** @@ -28,3 +31,47 @@ export function formatExtensionErrorMessage( ): string { return errorMsg.length < MAX_ERROR_MESSAGE_LENGTH ? errorMsg : fallback; } + +/** + * Shows toast notifications for extension load results. + * Uses grouped toast for multiple failures, individual toast for single failure. + * @param results - Array of extension load results from the backend + */ +export function showExtensionLoadResults(results: ExtensionLoadResult[] | null | undefined): void { + if (!results || results.length === 0) { + return; + } + + const failedExtensions = results.filter((r) => !r.success); + + if (failedExtensions.length === 0) { + return; + } + + if (failedExtensions.length === 1) { + const failed = failedExtensions[0]; + const errorMsg = failed.error || 'Unknown error'; + const recoverHints = createExtensionRecoverHints(errorMsg); + const displayMsg = formatExtensionErrorMessage(errorMsg, 'Failed to load extension'); + + toastService.error({ + title: failed.name, + msg: displayMsg, + traceback: errorMsg, + recoverHints, + }); + return; + } + + const extensionStatuses: ExtensionLoadingStatus[] = results.map((r) => { + const errorMsg = r.error || 'Unknown error'; + return { + name: r.name, + status: r.success ? 'success' : 'error', + error: r.success ? undefined : errorMsg, + recoverHints: r.success ? undefined : createExtensionRecoverHints(errorMsg), + }; + }); + + toastService.extensionLoading(extensionStatuses, results.length, true); +} diff --git a/ui/desktop/src/utils/navigationUtils.ts b/ui/desktop/src/utils/navigationUtils.ts index d9bbe36e7263..175fa87e9a70 100644 --- a/ui/desktop/src/utils/navigationUtils.ts +++ b/ui/desktop/src/utils/navigationUtils.ts @@ -1,5 +1,5 @@ import { NavigateFunction } from 'react-router-dom'; -import { Recipe } from '../api/types.gen'; +import { ExtensionConfig, Recipe } from '../api/types.gen'; export type View = | 'welcome' @@ -36,6 +36,9 @@ export type ViewOptions = { shareToken?: string; resumeSessionId?: string; pendingScheduleDeepLink?: string; + extensionConfigs?: ExtensionConfig[]; + workingDir?: string; + navigationId?: string; // Unique ID for each navigation to prevent duplicate session creation }; export const createNavigationHandler = (navigate: NavigateFunction) => { diff --git a/ui/desktop/src/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts index 8862b5c12d69..40627920e3c7 100644 --- a/ui/desktop/src/utils/providerUtils.ts +++ b/ui/desktop/src/utils/providerUtils.ts @@ -1,13 +1,9 @@ import { initializeBundledExtensions, syncBundledExtensions, - addToAgentOnStartup, } from '../components/settings/extensions'; import type { ExtensionConfig, FixedExtensionEntry } from '../components/ConfigContext'; import { Recipe, updateAgentProvider, updateFromSession } from '../api'; -import { toastService, ExtensionLoadingStatus } from '../toasts'; -import { errorMessage } from './conversionUtils'; -import { createExtensionRecoverHints } from './extensionErrorUtils'; // Helper function to substitute parameters in text export const substituteParameters = (text: string, params: Record): string => { @@ -29,7 +25,6 @@ export const initializeSystem = async ( options?: { getExtensions?: (b: boolean) => Promise; addExtension?: (name: string, config: ExtensionConfig, enabled: boolean) => Promise; - setIsExtensionsLoading?: (loading: boolean) => void; recipeParameters?: Record | null; recipe?: Recipe; } @@ -72,82 +67,11 @@ export const initializeSystem = async ( if (refreshedExtensions.length === 0) { await initializeBundledExtensions(options.addExtension); - refreshedExtensions = await options.getExtensions(false); } else { await syncBundledExtensions(refreshedExtensions, options.addExtension); } - - // Add enabled extensions to agent in parallel - const enabledExtensions = refreshedExtensions.filter((ext) => ext.enabled); - - if (enabledExtensions.length === 0) { - return; - } - - options?.setIsExtensionsLoading?.(true); - - // Initialize extension status tracking - const extensionStatuses: Map = new Map( - enabledExtensions.map((ext) => [ext.name, { name: ext.name, status: 'loading' as const }]) - ); - - // Show initial loading toast - const updateToast = (isComplete: boolean = false) => { - toastService.extensionLoading( - Array.from(extensionStatuses.values()), - enabledExtensions.length, - isComplete - ); - }; - - updateToast(); - - // Load extensions in parallel and update status - const extensionLoadingPromises = enabledExtensions.map(async (extensionConfig) => { - const extensionName = extensionConfig.name; - - try { - await addToAgentOnStartup({ - extensionConfig, - toastOptions: { silent: true }, // Silent since we're using grouped notification - sessionId, - }); - - // Update status to success - extensionStatuses.set(extensionName, { - name: extensionName, - status: 'success', - }); - updateToast(); - } catch (error) { - console.error(`Failed to load extension ${extensionName}:`, error); - - // Extract error message using shared utility - const errMsg = errorMessage(error); - - // Create recovery hints for "Ask goose" button - const recoverHints = createExtensionRecoverHints(errMsg); - - // Update status to error - extensionStatuses.set(extensionName, { - name: extensionName, - status: 'error', - error: errMsg, - recoverHints, - }); - updateToast(); - } - }); - - await Promise.allSettled(extensionLoadingPromises); - - // Show final completion toast - updateToast(true); - - options?.setIsExtensionsLoading?.(false); } catch (error) { console.error('Failed to initialize agent:', error); - options?.setIsExtensionsLoading?.(false); throw error; } }; From b45178489f77d3153de121d0d425112ec002b5bd Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 18 Dec 2025 09:36:02 -0800 Subject: [PATCH 11/24] fix refresh and forking --- ui/desktop/src/App.tsx | 42 +++++++++++++++++------------------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 08d720fb2b5f..af5ab2d96c5d 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -65,17 +65,17 @@ const PairRouteWrapper = ({ }) => { const { extensionsList } = useConfig(); const location = useLocation(); + const navigate = useNavigate(); const routeState = (location.state as PairRouteState) || {}; - const [searchParams, setSearchParams] = useSearchParams(); - const [initialMessage] = useState(routeState.initialMessage); + const [searchParams] = useSearchParams(); const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined; const recipeId = searchParams.get('recipeId') ?? undefined; const recipeDeeplinkFromConfig = window.appConfig?.get('recipeDeeplink') as string | undefined; - // Session ID comes from URL (set by Hub before navigating, or from session list/recipe deeplink) - const [sessionId, setSessionId] = useState( - routeState.resumeSessionId || resumeSessionId || chat.sessionId || undefined - ); + // Session ID and initialMessage come from route state (Hub, fork) or URL params (refresh, deeplink) + const sessionIdFromState = routeState.resumeSessionId; + const initialMessage = routeState.initialMessage; + const sessionId = sessionIdFromState || resumeSessionId || chat.sessionId || undefined; // Handle recipe deeplinks - create session if needed useEffect(() => { @@ -87,34 +87,26 @@ const PairRouteWrapper = ({ recipeDeeplink: recipeDeeplinkFromConfig, allExtensions: extensionsList, }); - setSessionId(newSession.id); - setSearchParams( - (prev) => { - prev.set('resumeSessionId', newSession.id); - prev.delete('recipeId'); - return prev; - }, - { replace: true } - ); + navigate(`/pair?resumeSessionId=${newSession.id}`, { + replace: true, + state: { resumeSessionId: newSession.id }, + }); } catch (error) { console.error('Failed to create session for recipe:', error); } })(); } - }, [recipeId, recipeDeeplinkFromConfig, sessionId, extensionsList, setSearchParams]); + }, [recipeId, recipeDeeplinkFromConfig, sessionId, extensionsList, navigate]); - // Sync URL with session ID for refresh support + // Sync URL with session ID for refresh support (only if not already in URL) useEffect(() => { if (sessionId && sessionId !== resumeSessionId) { - setSearchParams( - (prev) => { - prev.set('resumeSessionId', sessionId); - return prev; - }, - { replace: true } - ); + navigate(`/pair?resumeSessionId=${sessionId}`, { + replace: true, + state: { resumeSessionId: sessionIdFromState, initialMessage }, // Preserve state + }); } - }, [sessionId, resumeSessionId, setSearchParams]); + }, [sessionId, resumeSessionId, navigate, sessionIdFromState, initialMessage]); return ( Date: Thu, 18 Dec 2025 11:58:20 -0800 Subject: [PATCH 12/24] eagerly start loading extensions in the background --- crates/goose-server/src/routes/agent.rs | 59 +++++++++++++++++-- crates/goose-server/src/routes/agent_utils.rs | 17 +++++- crates/goose-server/src/state.rs | 44 ++++++++++++++ crates/goose/src/agents/extension_manager.rs | 10 ++-- crates/goose/src/agents/mod.rs | 2 +- 5 files changed, 121 insertions(+), 11 deletions(-) diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index a0e885a53e15..16b580abbe27 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -159,6 +159,7 @@ pub struct RestartAgentResponse { (status = 500, description = "Internal server error", body = ErrorResponse) ) )] +#[allow(clippy::too_many_lines)] async fn start_agent( State(state): State>, Json(payload): Json, @@ -262,6 +263,41 @@ async fn start_agent( } })?; + // Eagerly start loading extensions in the background + let session_for_spawn = session.clone(); + let state_for_spawn = state.clone(); + let session_id_for_task = session.id.clone(); + let task = tokio::spawn(async move { + match state_for_spawn + .get_agent(session_for_spawn.id.clone()) + .await + { + Ok(agent) => { + agent + .set_working_dir(session_for_spawn.working_dir.clone()) + .await; + + let results = restore_agent_extensions(agent, &session_for_spawn).await; + tracing::debug!( + "Background extension loading completed for session {}", + session_for_spawn.id + ); + results + } + Err(e) => { + tracing::warn!( + "Failed to create agent for background extension loading: {}", + e + ); + vec![] + } + } + }); + + state + .set_extension_loading_task(session_id_for_task, task) + .await; + Ok(Json(session)) } @@ -301,11 +337,26 @@ async fn resume_agent( status: code, })?; - let provider_result = restore_agent_provider(&agent, &session, &payload.session_id); - let extensions_future = restore_agent_extensions(agent.clone(), &session); + restore_agent_provider(&agent, &session, &payload.session_id).await?; + + let extension_results = + if let Some(results) = state.take_extension_loading_task(&payload.session_id).await { + tracing::debug!( + "Using background extension loading results for session {}", + payload.session_id + ); + state + .remove_extension_loading_task(&payload.session_id) + .await; + results + } else { + tracing::debug!( + "No background task found, loading extensions for session {}", + payload.session_id + ); + restore_agent_extensions(agent.clone(), &session).await + }; - let (provider_result, extension_results) = tokio::join!(provider_result, extensions_future); - provider_result?; Some(extension_results) } else { None diff --git a/crates/goose-server/src/routes/agent_utils.rs b/crates/goose-server/src/routes/agent_utils.rs index 1f5d9a35cd94..4ba3489bdece 100644 --- a/crates/goose-server/src/routes/agent_utils.rs +++ b/crates/goose-server/src/routes/agent_utils.rs @@ -1,6 +1,6 @@ use crate::routes::errors::ErrorResponse; use axum::http::StatusCode; -use goose::agents::Agent; +use goose::agents::{normalize, Agent}; use goose::config::Config; use goose::model::ModelConfig; use goose::providers::create; @@ -87,6 +87,21 @@ pub async fn restore_agent_extensions( async move { let name = config_clone.name().to_string(); + let normalized_name = normalize(&name); + + if agent_ref + .extension_manager + .is_extension_enabled(&normalized_name) + .await + { + tracing::debug!("Extension {} already loaded, skipping", name); + return ExtensionLoadResult { + name, + success: true, + error: None, + }; + } + match agent_ref.add_extension(config_clone).await { Ok(_) => ExtensionLoadResult { name, diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index 4a9c582e39e2..2ebc5a0b53f3 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -6,9 +6,14 @@ use std::path::PathBuf; use std::sync::atomic::AtomicUsize; use std::sync::Arc; use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use crate::routes::agent_utils::ExtensionLoadResult; use crate::tunnel::TunnelManager; +type ExtensionLoadingTasks = + Arc>>>>>>>; + #[derive(Clone)] pub struct AppState { pub(crate) agent_manager: Arc, @@ -17,6 +22,7 @@ pub struct AppState { /// Tracks sessions that have already emitted recipe telemetry to prevent double counting. recipe_session_tracker: Arc>>, pub tunnel_manager: Arc, + pub extension_loading_tasks: ExtensionLoadingTasks, } impl AppState { @@ -30,9 +36,47 @@ impl AppState { session_counter: Arc::new(AtomicUsize::new(0)), recipe_session_tracker: Arc::new(Mutex::new(HashSet::new())), tunnel_manager, + extension_loading_tasks: Arc::new(Mutex::new(HashMap::new())), })) } + pub async fn set_extension_loading_task( + &self, + session_id: String, + task: JoinHandle>, + ) { + let mut tasks = self.extension_loading_tasks.lock().await; + tasks.insert(session_id, Arc::new(Mutex::new(Some(task)))); + } + + pub async fn take_extension_loading_task( + &self, + session_id: &str, + ) -> Option> { + let task_holder = { + let tasks = self.extension_loading_tasks.lock().await; + tasks.get(session_id).cloned() + }; + + if let Some(holder) = task_holder { + let task = holder.lock().await.take(); + if let Some(handle) = task { + match handle.await { + Ok(results) => return Some(results), + Err(e) => { + tracing::warn!("Background extension loading task failed: {}", e); + } + } + } + } + None + } + + pub async fn remove_extension_loading_task(&self, session_id: &str) { + let mut tasks = self.extension_loading_tasks.lock().await; + tasks.remove(session_id); + } + pub fn scheduler(&self) -> Arc { self.agent_manager.scheduler() } diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 16c720baf68a..13fe29c143ca 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -133,7 +133,7 @@ impl ResourceItem { /// Sanitizes a string by replacing invalid characters with underscores. /// Valid characters match [a-zA-Z0-9_-] -fn normalize(input: String) -> String { +pub fn normalize(input: &str) -> String { let mut result = String::with_capacity(input.len()); for c in input.chars() { result.push(match c { @@ -316,7 +316,7 @@ impl ExtensionManager { working_dir: Option, ) -> ExtensionResult<()> { let config_name = config.key().to_string(); - let sanitized_name = normalize(config_name.clone()); + let sanitized_name = normalize(&config_name); let mut temp_dir = None; /// Helper function to merge environment variables from direct envs and keychain-stored env_keys @@ -567,7 +567,7 @@ impl ExtensionManager { } ExtensionConfig::Platform { name, .. } => { // Normalize the name to match the key used in PLATFORM_EXTENSIONS - let normalized_key = normalize(name.clone()); + let normalized_key = normalize(name); let def = PLATFORM_EXTENSIONS .get(normalized_key.as_str()) .ok_or_else(|| { @@ -660,7 +660,7 @@ impl ExtensionManager { /// Get aggregated usage statistics pub async fn remove_extension(&self, name: &str) -> ExtensionResult<()> { - let sanitized_name = normalize(name.to_string()); + let sanitized_name = normalize(name); self.extensions.lock().await.remove(&sanitized_name); Ok(()) } @@ -1375,7 +1375,7 @@ mod tests { client: McpClientBox, available_tools: Vec, ) { - let sanitized_name = normalize(name.clone()); + let sanitized_name = normalize(&name); let config = ExtensionConfig::Builtin { name: name.clone(), display_name: Some(name.clone()), diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 62676edee19e..520431ee6c82 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -29,7 +29,7 @@ pub mod types; pub use agent::{Agent, AgentEvent, MANUAL_COMPACT_TRIGGERS}; pub use extension::ExtensionConfig; -pub use extension_manager::ExtensionManager; +pub use extension_manager::{normalize, ExtensionManager}; pub use prompt_manager::PromptManager; pub use subagent_task_config::TaskConfig; pub use types::{FrontendTool, RetryConfig, SessionConfig, SuccessCheck}; From 303b4e24f140b08b1557ce6de00d2cc4cdbc2605 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 18 Dec 2025 12:16:09 -0800 Subject: [PATCH 13/24] prevent resending initial message on refresh --- ui/desktop/src/App.tsx | 2 +- ui/desktop/src/components/BaseChat.tsx | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index af5ab2d96c5d..3968134e2dab 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -103,7 +103,7 @@ const PairRouteWrapper = ({ if (sessionId && sessionId !== resumeSessionId) { navigate(`/pair?resumeSessionId=${sessionId}`, { replace: true, - state: { resumeSessionId: sessionIdFromState, initialMessage }, // Preserve state + state: { resumeSessionId: sessionIdFromState, initialMessage }, }); } }, [sessionId, resumeSessionId, navigate, sessionIdFromState, initialMessage]); diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 5744aa33077b..f787ed60d154 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -133,11 +133,16 @@ function BaseChatContent({ if (initialMessage) { hasAutoSubmittedRef.current = true; handleSubmit(initialMessage); + // Clear initialMessage from navigation state to prevent re-sending on refresh + navigate(location.pathname + location.search, { + replace: true, + state: { ...location.state, initialMessage: undefined }, + }); } else if (shouldStartAgent) { hasAutoSubmittedRef.current = true; handleSubmit(''); } - }, [session, initialMessage, searchParams, handleSubmit]); + }, [session, initialMessage, searchParams, handleSubmit, navigate, location]); const handleFormSubmit = (e: React.FormEvent) => { const customEvent = e as unknown as CustomEvent; From f4fb87872255fe2b7eb6c881b100c6c510f03f7b Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 18 Dec 2025 12:36:34 -0800 Subject: [PATCH 14/24] change restarting agent display --- ui/desktop/src/components/BaseChat.tsx | 2 ++ ui/desktop/src/components/ChatInput.tsx | 24 ++++++++---------------- ui/desktop/src/hooks/useChatStream.ts | 2 ++ 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index f787ed60d154..9327a6fbda81 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -95,6 +95,7 @@ function BaseChatContent({ session, messages, chatState, + setChatState, handleSubmit, submitElicitationResponse, stopStreaming, @@ -409,6 +410,7 @@ function BaseChatContent({ sessionId={sessionId} handleSubmit={handleFormSubmit} chatState={chatState} + setChatState={setChatState} onStop={stopStreaming} commandHistory={commandHistory} initialValue={initialPrompt} diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index e35e5d159699..807a342e47e2 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -73,6 +73,7 @@ interface ChatInputProps { sessionId: string | null; handleSubmit: (e: React.FormEvent) => void; chatState: ChatState; + setChatState?: (state: ChatState) => void; onStop?: () => void; commandHistory?: string[]; initialValue?: string; @@ -104,6 +105,7 @@ export default function ChatInput({ sessionId, handleSubmit, chatState = ChatState.Idle, + setChatState, onStop, commandHistory = [], initialValue = '', @@ -152,7 +154,6 @@ export default function ChatInput({ const [showEditRecipeModal, setShowEditRecipeModal] = useState(false); const [isFilePickerOpen, setIsFilePickerOpen] = useState(false); const [sessionWorkingDir, setSessionWorkingDir] = useState(null); - const [isRestartingAgent, setIsRestartingAgent] = useState(false); useEffect(() => { if (!sessionId) { @@ -1132,7 +1133,7 @@ export default function ChatInput({ isAnyDroppedFileLoading || isRecording || isTranscribing || - isRestartingAgent; + chatState === ChatState.RestartingAgent; // Queue management functions - no storage persistence, only in-memory const handleRemoveQueuedMessage = (messageId: string) => { @@ -1383,7 +1384,7 @@ export default function ChatInput({ ? 'Recording...' : isTranscribing ? 'Transcribing...' - : isRestartingAgent + : chatState === ChatState.RestartingAgent ? 'Restarting agent...' : 'Send'}

@@ -1410,15 +1411,6 @@ export default function ChatInput({ )}
)} - - {isRestartingAgent && !isRecording && !isTranscribing && ( -
- - - Restarting agent... - -
- )}
@@ -1544,8 +1536,8 @@ export default function ChatInput({ onWorkingDirChange(newDir); } }} - onRestartStart={() => setIsRestartingAgent(true)} - onRestartEnd={() => setIsRestartingAgent(false)} + onRestartStart={() => setChatState?.(ChatState.RestartingAgent)} + onRestartEnd={() => setChatState?.(ChatState.Idle)} />
@@ -1593,8 +1585,8 @@ export default function ChatInput({
setIsRestartingAgent(true)} - onRestartEnd={() => setIsRestartingAgent(false)} + onRestartStart={() => setChatState?.(ChatState.RestartingAgent)} + onRestartEnd={() => setChatState?.(ChatState.Idle)} /> {sessionId && messages.length > 0 && ( <> diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index 6e4efd6d5b87..fc6102b9f520 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -34,6 +34,7 @@ interface UseChatStreamReturn { session?: Session; messages: Message[]; chatState: ChatState; + setChatState: (state: ChatState) => void; handleSubmit: (userMessage: string) => Promise; submitElicitationResponse: ( elicitationId: string, @@ -512,6 +513,7 @@ export function useChatStream({ messages: maybe_cached_messages, session: maybe_cached_session, chatState, + setChatState, handleSubmit, submitElicitationResponse, stopStreaming, From 8ee99b07ec5a9141e9c0f91f1fdc37c81aa1ed98 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 18 Dec 2025 12:44:06 -0800 Subject: [PATCH 15/24] cleanup --- crates/goose-server/src/routes/session.rs | 1 - ui/desktop/src/components/sessions/SessionListView.tsx | 5 ----- ui/desktop/src/store/extensionOverrides.ts | 6 ------ 3 files changed, 12 deletions(-) diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index 1719444820d1..b9fe20ee70a2 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -44,7 +44,6 @@ pub struct UpdateSessionUserRecipeValuesRequest { #[derive(Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateSessionWorkingDirRequest { - /// New working directory path working_dir: String, } diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index 359872781f70..7f8b6e0f6d92 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -35,10 +35,8 @@ import { ExtensionData, } from '../../api'; -// Helper to extract extension names from session's extension_data function getSessionExtensionNames(extensionData: ExtensionData): string[] { try { - // extension_data structure: { "enabled_extensions.v0": { "extensions": [...] } } const v0Data = extensionData?.['enabled_extensions.v0'] as | { extensions?: ExtensionConfig[] } | undefined; @@ -71,7 +69,6 @@ const EditSessionModal = React.memo( if (session && isOpen) { setDescription(session.name); } else if (!isOpen) { - // Reset state when modal closes setDescription(''); setIsUpdating(false); } @@ -94,8 +91,6 @@ const EditSessionModal = React.memo( throwOnError: true, }); await onSave(session.id, trimmedDescription); - - // Close modal, then show success toast on a timeout to let the UI update complete. onClose(); setTimeout(() => { toast.success('Session description updated successfully'); diff --git a/ui/desktop/src/store/extensionOverrides.ts b/ui/desktop/src/store/extensionOverrides.ts index 8604f6735397..5755126961a0 100644 --- a/ui/desktop/src/store/extensionOverrides.ts +++ b/ui/desktop/src/store/extensionOverrides.ts @@ -13,7 +13,6 @@ const state: { extensionOverrides: new Map(), }; -// Extension override functions export function setExtensionOverride(name: string, enabled: boolean): void { state.extensionOverrides.set(name, enabled); } @@ -34,12 +33,10 @@ export function clearExtensionOverrides(): void { state.extensionOverrides.clear(); } -// Get extension configs with overrides applied export function getExtensionConfigsWithOverrides( allExtensions: Array<{ name: string; enabled: boolean } & Omit> ): ExtensionConfig[] { if (state.extensionOverrides.size === 0) { - // No overrides, return global enabled extensions return allExtensions .filter((ext) => ext.enabled) .map((ext) => { @@ -48,14 +45,11 @@ export function getExtensionConfigsWithOverrides( }); } - // Apply overrides return allExtensions .filter((ext) => { - // Check if we have an override for this extension if (state.extensionOverrides.has(ext.name)) { return state.extensionOverrides.get(ext.name); } - // Otherwise use the global enabled state return ext.enabled; }) .map((ext) => { From 57c2e5ddb4c172f7d28dd16f0aaa7ee7b17757f5 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 18 Dec 2025 12:58:48 -0800 Subject: [PATCH 16/24] cleanup --- ui/desktop/src/utils/navigationUtils.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/ui/desktop/src/utils/navigationUtils.ts b/ui/desktop/src/utils/navigationUtils.ts index 175fa87e9a70..aaa6ba67b1c6 100644 --- a/ui/desktop/src/utils/navigationUtils.ts +++ b/ui/desktop/src/utils/navigationUtils.ts @@ -1,5 +1,5 @@ import { NavigateFunction } from 'react-router-dom'; -import { ExtensionConfig, Recipe } from '../api/types.gen'; +import { Recipe } from '../api/types.gen'; export type View = | 'welcome' @@ -19,9 +19,7 @@ export type View = | 'recipes' | 'permission'; -// TODO(Douwe): check these for usage, especially key: string for resetChat export type ViewOptions = { - extensionId?: string; showEnvVars?: boolean; deepLinkConfig?: unknown; sessionDetails?: unknown; @@ -32,13 +30,9 @@ export type ViewOptions = { parentViewOptions?: ViewOptions; disableAnimation?: boolean; initialMessage?: string; - resetChat?: boolean; shareToken?: string; resumeSessionId?: string; pendingScheduleDeepLink?: string; - extensionConfigs?: ExtensionConfig[]; - workingDir?: string; - navigationId?: string; // Unique ID for each navigation to prevent duplicate session creation }; export const createNavigationHandler = (navigate: NavigateFunction) => { From 88d552e7f60c18f7de6dfd358771ac5e683fa6fc Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 18 Dec 2025 13:30:36 -0800 Subject: [PATCH 17/24] bring back extension success toast --- ui/desktop/src/utils/extensionErrorUtils.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/ui/desktop/src/utils/extensionErrorUtils.ts b/ui/desktop/src/utils/extensionErrorUtils.ts index 17b777750b22..369a64c82cec 100644 --- a/ui/desktop/src/utils/extensionErrorUtils.ts +++ b/ui/desktop/src/utils/extensionErrorUtils.ts @@ -34,7 +34,7 @@ export function formatExtensionErrorMessage( /** * Shows toast notifications for extension load results. - * Uses grouped toast for multiple failures, individual toast for single failure. + * Uses grouped toast for multiple extensions, individual error toast for single failed extension. * @param results - Array of extension load results from the backend */ export function showExtensionLoadResults(results: ExtensionLoadResult[] | null | undefined): void { @@ -44,11 +44,7 @@ export function showExtensionLoadResults(results: ExtensionLoadResult[] | null | const failedExtensions = results.filter((r) => !r.success); - if (failedExtensions.length === 0) { - return; - } - - if (failedExtensions.length === 1) { + if (results.length === 1 && failedExtensions.length === 1) { const failed = failedExtensions[0]; const errorMsg = failed.error || 'Unknown error'; const recoverHints = createExtensionRecoverHints(errorMsg); From 344918bf51aa6a914f247772d986486b2f896fbe Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 18 Dec 2025 13:54:28 -0800 Subject: [PATCH 18/24] fix bad merge --- crates/goose-server/src/routes/agent_utils.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/goose-server/src/routes/agent_utils.rs b/crates/goose-server/src/routes/agent_utils.rs index 7a67e19c168e..4ba3489bdece 100644 --- a/crates/goose-server/src/routes/agent_utils.rs +++ b/crates/goose-server/src/routes/agent_utils.rs @@ -100,10 +100,6 @@ pub async fn restore_agent_extensions( success: true, error: None, }; - goose::posthog::emit_error( - "extension_load_failed", - &format!("{}: {}", config_clone.name(), e), - ); } match agent_ref.add_extension(config_clone).await { From 272dcc25c9f7736df1ae4888443e33093d3bfccd Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 18 Dec 2025 14:08:42 -0800 Subject: [PATCH 19/24] update extensions default text --- ui/desktop/src/components/extensions/ExtensionsView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/desktop/src/components/extensions/ExtensionsView.tsx b/ui/desktop/src/components/extensions/ExtensionsView.tsx index 0bbb3ff558b1..34e243aaa004 100644 --- a/ui/desktop/src/components/extensions/ExtensionsView.tsx +++ b/ui/desktop/src/components/extensions/ExtensionsView.tsx @@ -105,7 +105,7 @@ export default function ExtensionsView({

Extensions enabled here are used as the default for new chats. You can also toggle - extensions using the menu during chat. + active extensions during chat.

{/* Action Buttons */} From da0a5fa69e023864ec1d291d7dc24468c63c0ff5 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 18 Dec 2025 14:36:14 -0800 Subject: [PATCH 20/24] auto hide the toast --- ui/desktop/src/toasts.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/desktop/src/toasts.tsx b/ui/desktop/src/toasts.tsx index b0e6dbaef695..c6d19cd1efbf 100644 --- a/ui/desktop/src/toasts.tsx +++ b/ui/desktop/src/toasts.tsx @@ -110,7 +110,7 @@ class ToastService { { ...commonToastOptions, toastId, - autoClose: false, + autoClose: isComplete ? 5000 : false, closeButton: true, closeOnClick: false, // Prevent closing when clicking to expand/collapse } From 1489ff239a4c524a6ac62bec8e9506817a60846e Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 18 Dec 2025 14:50:32 -0800 Subject: [PATCH 21/24] cleanup --- crates/goose-server/src/routes/agent.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 9bcd29a63146..aad1533a5e5b 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -610,7 +610,6 @@ async fn agent_add_extension( ErrorResponse::internal(format!("Failed to add extension: {}", e)) })?; - // Persist the updated extension state to the session persist_session_extensions(&agent, &request.session_id).await?; Ok(StatusCode::OK) } @@ -633,7 +632,6 @@ async fn agent_remove_extension( let agent = state.get_agent(request.session_id.clone()).await?; agent.remove_extension(&request.name).await?; - // Persist the updated extension state to the session persist_session_extensions(&agent, &request.session_id).await?; Ok(StatusCode::OK) From c93d66014f1f852b99ee75fd2db16037692c6a71 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 18 Dec 2025 14:55:59 -0800 Subject: [PATCH 22/24] add back tracking lost in merge --- ui/desktop/src/App.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 606609071016..3d6fff5135ff 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -94,6 +94,11 @@ const PairRouteWrapper = ({ }); } catch (error) { console.error('Failed to create session for recipe:', error); + trackErrorWithContext(error, { + component: 'PairRouteWrapper', + action: 'create_session', + recoverable: true, + }); } })(); } From d16d975c01a476a43089bd512565b7626efc414a Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 18 Dec 2025 15:00:04 -0800 Subject: [PATCH 23/24] cleanup --- .../src/components/bottom_menu/BottomMenuExtensionSelection.tsx | 1 - ui/desktop/src/components/sessions/SessionsInsights.tsx | 1 - ui/desktop/src/components/settings/app/UpdateSection.tsx | 1 - 3 files changed, 3 deletions(-) diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx index 64e100ebd831..dfe99b2a861e 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx @@ -36,7 +36,6 @@ export const BottomMenuExtensionSelection = ({ const { extensionsList: allExtensions } = useConfig(); const isHubView = !sessionId; - // Cleanup timeout on unmount useEffect(() => { return () => { if (sortTimeoutRef.current) { diff --git a/ui/desktop/src/components/sessions/SessionsInsights.tsx b/ui/desktop/src/components/sessions/SessionsInsights.tsx index 15c2ad53578a..b9ec0e2cf7dc 100644 --- a/ui/desktop/src/components/sessions/SessionsInsights.tsx +++ b/ui/desktop/src/components/sessions/SessionsInsights.tsx @@ -78,7 +78,6 @@ export function SessionInsights() { loadInsights(); loadRecentSessions(); - // Cleanup timeout on unmount return () => { if (loadingTimeout) { window.clearTimeout(loadingTimeout); diff --git a/ui/desktop/src/components/settings/app/UpdateSection.tsx b/ui/desktop/src/components/settings/app/UpdateSection.tsx index c694980e863e..b68aea9e6cad 100644 --- a/ui/desktop/src/components/settings/app/UpdateSection.tsx +++ b/ui/desktop/src/components/settings/app/UpdateSection.tsx @@ -125,7 +125,6 @@ export default function UpdateSection() { } }); - // Cleanup timeout on unmount return () => { if (progressTimeoutRef.current) { clearTimeout(progressTimeoutRef.current); From 1d02bb75d9e839dcf32e51e3200cb0552ea517b0 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 18 Dec 2025 15:27:51 -0800 Subject: [PATCH 24/24] use format extension name helper --- .../GroupedExtensionLoadingToast.tsx | 71 +++++++++++-------- .../BottomMenuExtensionSelection.tsx | 6 +- .../components/sessions/SessionListView.tsx | 16 ++--- .../subcomponents/ExtensionList.tsx | 8 ++- 4 files changed, 54 insertions(+), 47 deletions(-) diff --git a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx index 7f69415d321c..47ca1f89d48b 100644 --- a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx +++ b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx @@ -6,6 +6,7 @@ import { startNewSession } from '../sessions'; import { useNavigation } from '../hooks/useNavigation'; import { formatExtensionErrorMessage } from '../utils/extensionErrorUtils'; import { getInitialWorkingDir } from '../utils/workingDir'; +import { formatExtensionName } from './settings/extensions/subcomponents/ExtensionList'; export interface ExtensionLoadingStatus { name: string; @@ -92,45 +93,53 @@ export function GroupedExtensionLoadingToast({
- {extensions.map((ext) => ( -
-
- {getStatusIcon(ext.status)} -
{ext.name}
-
- {ext.status === 'error' && ext.error && ( -
-
- {formatExtensionErrorMessage(ext.error, 'Failed to add extension')} -
-
- {ext.recoverHints && setView && ( + {extensions.map((ext) => { + const friendlyName = formatExtensionName(ext.name); + + return ( +
+
+ {getStatusIcon(ext.status)} +
{friendlyName}
+
+ {ext.status === 'error' && ext.error && ( +
+
+ {formatExtensionErrorMessage(ext.error, 'Failed to add extension')} +
+
+ {ext.recoverHints && setView && ( + + )} - )} - +
-
- )} -
- ))} + )} +
+ ); + })}
diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx index dfe99b2a861e..6a5cfbbb2cc3 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx @@ -5,7 +5,7 @@ import { Input } from '../ui/input'; import { Switch } from '../ui/switch'; import { FixedExtensionEntry, useConfig } from '../ConfigContext'; import { toastService } from '../../toasts'; -import { getFriendlyTitle } from '../settings/extensions/subcomponents/ExtensionList'; +import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList'; import { ExtensionConfig, getSessionExtensions } from '../../api'; import { addToAgent, removeFromAgent } from '../settings/extensions/agent-api'; import { @@ -95,7 +95,7 @@ export const BottomMenuExtensionSelection = ({ toastService.success({ title: 'Extension Updated', - msg: `${extensionConfig.name} will be ${!currentState ? 'enabled' : 'disabled'} in new chats`, + msg: `${formatExtensionName(extensionConfig.name)} will be ${!currentState ? 'enabled' : 'disabled'} in new chats`, }); return; } @@ -267,7 +267,7 @@ export const BottomMenuExtensionSelection = ({ title={ext.description || ext.name} >
- {getFriendlyTitle(ext)} + {formatExtensionName(ext.name)}
e.stopPropagation()}> { - // display_name exists on some ExtensionConfig variants but not all - const displayName = (ext as { display_name?: string }).display_name; - return displayName || ext.name; - }); + return enabledExtensionData.extensions.map((ext) => formatExtensionName(ext.name)); } catch { return []; } @@ -638,10 +635,7 @@ const SessionListView: React.FC = React.memo( -
e.stopPropagation()} - > +
e.stopPropagation()}> {extensionNames.length}
diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx index e15e86007892..378bc5c7fda6 100644 --- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx +++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx @@ -97,14 +97,18 @@ export default function ExtensionList({ } // Helper functions -export function getFriendlyTitle(extension: FixedExtensionEntry): string { - const name = (extension.type === 'builtin' && extension.display_name) || extension.name; +export function formatExtensionName(name: string): string { return name .split(/[-_]/) // Split on hyphens and underscores .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); } +export function getFriendlyTitle(extension: FixedExtensionEntry): string { + const name = (extension.type === 'builtin' && extension.display_name) || extension.name; + return formatExtensionName(name); +} + function normalizeExtensionName(name: string): string { return name.toLowerCase().replace(/\s+/g, ''); }