diff --git a/Cargo.lock b/Cargo.lock index a27bb55693f2..617eecbb1ba0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1432,9 +1432,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.62" +version = "4.5.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "004eef6b14ce34759aa7de4aea3217e368f463f46a3ed3764ca4b5a4404003b4" +checksum = "4c0da80818b2d95eca9aa614a30783e42f62bf5fdfee24e68cfb960b071ba8d1" dependencies = [ "clap", ] diff --git a/crates/goose-mcp/src/developer/rmcp_developer.rs b/crates/goose-mcp/src/developer/rmcp_developer.rs index eb788086fe42..0cfe574a2be8 100644 --- a/crates/goose-mcp/src/developer/rmcp_developer.rs +++ b/crates/goose-mcp/src/developer/rmcp_developer.rs @@ -968,6 +968,10 @@ impl DeveloperServer { .and_then(|s| s.to_str()) .unwrap_or("bash"); + let working_dir = std::env::var("GOOSE_WORKING_DIR") + .ok() + .map(std::path::PathBuf::from); + if let Some(ref env_file) = self.bash_env_file { if shell_name == "bash" { shell_config.envs.push(( @@ -977,7 +981,7 @@ impl DeveloperServer { } } - let mut command = configure_shell_command(&shell_config, command); + let mut command = configure_shell_command(&shell_config, command, working_dir.as_deref()); if self.extend_path_with_shell { if let Err(e) = get_shell_path_dirs() diff --git a/crates/goose-mcp/src/developer/shell.rs b/crates/goose-mcp/src/developer/shell.rs index 51f91dc4faa5..a05242833d7b 100644 --- a/crates/goose-mcp/src/developer/shell.rs +++ b/crates/goose-mcp/src/developer/shell.rs @@ -109,8 +109,14 @@ pub fn normalize_line_endings(text: &str) -> String { pub fn configure_shell_command( shell_config: &ShellConfig, command: &str, + working_dir: Option<&std::path::Path>, ) -> tokio::process::Command { let mut command_builder = tokio::process::Command::new(&shell_config.executable); + + if let Some(dir) = working_dir { + command_builder.current_dir(dir); + } + command_builder .stdout(Stdio::piped()) .stderr(Stdio::piped()) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 04debe7dec2d..384fad28e1dc 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -354,6 +354,8 @@ derive_utoipa!(Icon as IconSchema); super::routes::config_management::get_pricing, super::routes::agent::start_agent, super::routes::agent::resume_agent, + super::routes::agent::restart_agent, + super::routes::agent::update_working_dir, super::routes::agent::get_tools, super::routes::agent::read_resource, super::routes::agent::call_tool, @@ -372,6 +374,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, @@ -431,6 +434,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, @@ -529,9 +533,14 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::CallToolResponse, super::routes::agent::StartAgentRequest, super::routes::agent::ResumeAgentRequest, + super::routes::agent::RestartAgentRequest, + super::routes::agent::UpdateWorkingDirRequest, super::routes::agent::UpdateFromSessionRequest, super::routes::agent::AddExtensionRequest, super::routes::agent::RemoveExtensionRequest, + super::routes::agent::ResumeAgentResponse, + super::routes::agent::RestartAgentResponse, + goose::agents::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 70b871fa7048..e4c150c3ff3b 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -10,6 +10,7 @@ use axum::{ routing::{get, post}, Json, Router, }; +use goose::agents::ExtensionLoadResult; use goose::config::PermissionManager; use base64::Engine; @@ -20,8 +21,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, @@ -34,7 +36,7 @@ use std::path::PathBuf; use std::sync::atomic::Ordering; use std::sync::Arc; use tokio_util::sync::CancellationToken; -use tracing::{error, warn}; +use tracing::error; #[derive(Deserialize, utoipa::ToSchema)] pub struct UpdateFromSessionRequest { @@ -63,6 +65,8 @@ pub struct StartAgentRequest { recipe_id: Option, #[serde(default)] recipe_deeplink: Option, + #[serde(default)] + extension_overrides: Option>, } #[derive(Deserialize, utoipa::ToSchema)] @@ -70,6 +74,17 @@ pub struct StopAgentRequest { session_id: String, } +#[derive(Deserialize, utoipa::ToSchema)] +pub struct RestartAgentRequest { + session_id: String, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct UpdateWorkingDirRequest { + session_id: String, + working_dir: String, +} + #[derive(Deserialize, utoipa::ToSchema)] pub struct ResumeAgentRequest { session_id: String, @@ -122,6 +137,18 @@ pub struct CallToolResponse { _meta: Option, } +#[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", @@ -133,6 +160,7 @@ pub struct CallToolResponse { (status = 500, description = "Internal server error", body = ErrorResponse) ) )] +#[allow(clippy::too_many_lines)] async fn start_agent( State(state): State>, Json(payload): Json, @@ -144,6 +172,7 @@ async fn start_agent( recipe, recipe_id, recipe_deeplink, + extension_overrides, } = payload; let original_recipe = if let Some(deeplink) = recipe_deeplink { @@ -191,30 +220,83 @@ 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) { + tracing::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, + } + })?; + + // 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) => { + let results = agent.load_extensions_from_session(&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)) } @@ -223,7 +305,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") @@ -232,7 +314,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) @@ -246,7 +328,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 @@ -255,81 +337,41 @@ async fn resume_agent( status: code, })?; - let config = Config::global(); + agent + .restore_provider_from_session(&session) + .await + .map_err(|e| ErrorResponse { + message: e.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; - let provider_result = async { - let provider_name = session - .provider_name - .clone() - .or_else(|| config.get_goose_provider().ok()) - .ok_or_else(|| ErrorResponse { - message: "Could not configure agent: missing provider".into(), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - let model_config = match session.model_config.clone() { - Some(saved_config) => saved_config, - None => { - let model_name = config.get_goose_model().map_err(|_| ErrorResponse { - message: "Could not configure agent: missing model".into(), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - ModelConfig::new(&model_name).map_err(|e| ErrorResponse { - message: format!("Could not configure agent: invalid model {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - })? - } + 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 + ); + agent.load_extensions_from_session(&session).await }; - let provider = - create(&provider_name, model_config) - .await - .map_err(|e| ErrorResponse { - message: format!("Could not create provider: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - agent - .update_provider(provider, &payload.session_id) - .await - .map_err(|e| ErrorResponse { - message: format!("Could not configure agent: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - }) - }; - - let extensions_result = async { - let enabled_configs = goose::config::get_enabled_extensions(); - let agent_clone = agent.clone(); - - let extension_futures = enabled_configs - .into_iter() - .map(|config| { - let config_clone = config.clone(); - let agent_ref = agent_clone.clone(); - - async move { - if let Err(e) = agent_ref.add_extension(config_clone.clone()).await { - warn!("Failed to load extension {}: {}", config_clone.name(), e); - goose::posthog::emit_error( - "extension_load_failed", - &format!("{}: {}", config_clone.name(), e), - ); - } - Ok::<_, ErrorResponse>(()) - } - }) - .collect::>(); - - futures::future::join_all(extension_futures).await; - Ok::<(), ErrorResponse>(()) // Fixed type annotation - }; - - let (provider_result, _) = tokio::join!(provider_result, extensions_result); - provider_result?; - } + Some(extension_results) + } else { + None + }; - Ok(Json(session)) + Ok(Json(ResumeAgentResponse { + session, + extension_results, + })) } #[utoipa::path( @@ -519,7 +561,8 @@ async fn agent_add_extension( Json(request): Json, ) -> Result { let extension_name = request.config.name(); - let agent = state.get_agent(request.session_id).await?; + let agent = state.get_agent(request.session_id.clone()).await?; + agent.add_extension(request.config).await.map_err(|e| { goose::posthog::emit_error( "extension_add_failed", @@ -527,6 +570,18 @@ async fn agent_add_extension( ); ErrorResponse::internal(format!("Failed to add extension: {}", e)) })?; + + // Persist here rather than in add_extension to ensure we only save state + // after the extension successfully loads. This prevents failed extensions + // from being persisted as enabled in the session. + agent + .persist_extension_state(&request.session_id) + .await + .map_err(|e| { + error!("Failed to persist extension state: {}", e); + ErrorResponse::internal(format!("Failed to persist extension state: {}", e)) + })?; + Ok(StatusCode::OK) } @@ -545,8 +600,20 @@ async fn agent_remove_extension( State(state): State>, Json(request): Json, ) -> Result { - let agent = state.get_agent(request.session_id).await?; + let agent = state.get_agent(request.session_id.clone()).await?; agent.remove_extension(&request.name).await?; + + agent + .persist_extension_state(&request.session_id) + .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(StatusCode::OK) } @@ -578,6 +645,159 @@ async fn stop_agent( Ok(StatusCode::OK) } +async fn restart_agent_internal( + state: &Arc, + session_id: &str, + session: &Session, +) -> Result, ErrorResponse> { + // Remove existing agent (ignore error if not found) + let _ = state.agent_manager.remove_session(session_id).await; + + let agent = state + .get_agent_for_route(session_id.to_string()) + .await + .map_err(|code| ErrorResponse { + message: "Failed to create new agent during restart".into(), + status: code, + })?; + + let provider_future = agent.restore_provider_from_session(session); + let extensions_future = agent.load_extensions_from_session(session); + + let (provider_result, extension_results) = tokio::join!(provider_future, extensions_future); + provider_result.map_err(|e| ErrorResponse { + message: e.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + let context: HashMap<&str, Value> = HashMap::new(); + let desktop_prompt = + render_global_file("desktop_prompt.md", &context).expect("Prompt should render"); + let mut update_prompt = desktop_prompt; + + if let Some(ref recipe) = session.recipe { + match build_recipe_with_parameter_values( + recipe, + session.user_recipe_values.clone().unwrap_or_default(), + ) + .await + { + Ok(Some(recipe)) => { + if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, true).await { + update_prompt = prompt; + } + } + Ok(None) => { + // Recipe has missing parameters - use default prompt + } + Err(e) => { + return Err(ErrorResponse { + message: e.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR, + }); + } + } + } + agent.extend_system_prompt(update_prompt).await; + + Ok(extension_results) +} + +#[utoipa::path( + post, + path = "/agent/restart", + request_body = RestartAgentRequest, + responses( + (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") + ) +)] +async fn restart_agent( + State(state): State>, + Json(payload): Json, +) -> Result, ErrorResponse> { + let session_id = payload.session_id.clone(); + + let session = SessionManager::get_session(&session_id, false) + .await + .map_err(|err| { + error!("Failed to get session during restart: {}", err); + ErrorResponse { + message: format!("Failed to get session: {}", err), + status: StatusCode::NOT_FOUND, + } + })?; + + let extension_results = restart_agent_internal(&state, &session_id, &session).await?; + + Ok(Json(RestartAgentResponse { extension_results })) +} + +#[utoipa::path( + post, + path = "/agent/update_working_dir", + request_body = UpdateWorkingDirRequest, + responses( + (status = 200, description = "Working directory updated and agent restarted successfully"), + (status = 400, description = "Bad request - invalid directory path"), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 404, description = "Session not found"), + (status = 500, description = "Internal server error") + ) +)] +async fn update_working_dir( + State(state): State>, + Json(payload): Json, +) -> Result { + let session_id = payload.session_id.clone(); + let working_dir = payload.working_dir.trim(); + + if working_dir.is_empty() { + return Err(ErrorResponse { + message: "Working directory cannot be empty".into(), + status: StatusCode::BAD_REQUEST, + }); + } + + let path = PathBuf::from(working_dir); + if !path.exists() || !path.is_dir() { + return Err(ErrorResponse { + message: "Invalid directory path".into(), + status: StatusCode::BAD_REQUEST, + }); + } + + // Update the session's working directory + SessionManager::update_session(&session_id) + .working_dir(path) + .apply() + .await + .map_err(|e| { + error!("Failed to update session working directory: {}", e); + ErrorResponse { + message: format!("Failed to update working directory: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; + + // Get the updated session and restart the agent + let session = SessionManager::get_session(&session_id, false) + .await + .map_err(|err| { + error!("Failed to get session after working dir update: {}", err); + ErrorResponse { + message: format!("Failed to get session: {}", err), + status: StatusCode::NOT_FOUND, + } + })?; + + restart_agent_internal(&state, &session_id, &session).await?; + + Ok(StatusCode::OK) +} + #[utoipa::path( post, path = "/agent/read_resource", @@ -702,6 +922,8 @@ pub fn routes(state: Arc) -> Router { Router::new() .route("/agent/start", post(start_agent)) .route("/agent/resume", post(resume_agent)) + .route("/agent/restart", post(restart_agent)) + .route("/agent/update_working_dir", post(update_working_dir)) .route("/agent/tools", get(get_tools)) .route("/agent/read_resource", post(read_resource)) .route("/agent/call_tool", post(call_tool)) diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index 09dc95b1f3a7..1f3fc922bcaa 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::sync::Arc; @@ -393,6 +395,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)) @@ -407,5 +447,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/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index 4a9c582e39e2..845eccda8f6e 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -6,8 +6,13 @@ use std::path::PathBuf; use std::sync::atomic::AtomicUsize; use std::sync::Arc; use tokio::sync::Mutex; +use tokio::task::JoinHandle; use crate::tunnel::TunnelManager; +use goose::agents::ExtensionLoadResult; + +type ExtensionLoadingTasks = + Arc>>>>>>>; #[derive(Clone)] pub struct AppState { @@ -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/agent.rs b/crates/goose/src/agents/agent.rs index 1fb2165aca50..e5b6cbe6a44a 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -13,7 +13,7 @@ use super::platform_tools; use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE}; use crate::action_required_manager::ActionRequiredManager; use crate::agents::extension::{ExtensionConfig, ExtensionResult, ToolInfo}; -use crate::agents::extension_manager::{get_parameter_names, ExtensionManager}; +use crate::agents::extension_manager::{get_parameter_names, normalize, ExtensionManager}; use crate::agents::extension_manager_extension::MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE; use crate::agents::final_output_tool::{FINAL_OUTPUT_CONTINUATION_MESSAGE, FINAL_OUTPUT_TOOL_NAME}; use crate::agents::platform_tools::PLATFORM_MANAGE_SCHEDULE_TOOL_NAME; @@ -77,6 +77,14 @@ pub struct ToolCategorizeResult { pub filtered_response: Message, } +#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] +pub struct ExtensionLoadResult { + pub name: String, + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + /// The main goose Agent pub struct Agent { pub(super) provider: SharedProvider, @@ -565,6 +573,91 @@ impl Agent { Ok(()) } + /// Save current extension state to session by session_id + pub async fn persist_extension_state(&self, session_id: &str) -> Result<()> { + let extension_configs = self.extension_manager.get_extension_configs().await; + let extensions_state = EnabledExtensionsState::new(extension_configs); + + let session = SessionManager::get_session(session_id, false).await?; + let mut extension_data = session.extension_data.clone(); + + extensions_state + .to_extension_data(&mut extension_data) + .map_err(|e| anyhow!("Failed to serialize extension state: {}", e))?; + + SessionManager::update_session(session_id) + .extension_data(extension_data) + .apply() + .await?; + + Ok(()) + } + + /// Load extensions from session into the agent + /// Skips extensions that are already loaded + pub async fn load_extensions_from_session( + self: &Arc, + session: &Session, + ) -> Vec { + let session_extensions = + EnabledExtensionsState::from_extension_data(&session.extension_data); + let enabled_configs = match session_extensions { + Some(state) => state.extensions, + None => { + tracing::warn!( + "No extensions found in session {}. This is unexpected.", + session.id + ); + return vec![]; + } + }; + + let extension_futures = enabled_configs + .into_iter() + .map(|config| { + let config_clone = config.clone(); + let agent_ref = self.clone(); + + 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, + 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), + } + } + } + } + }) + .collect::>(); + + futures::future::join_all(extension_futures).await + } + pub async fn add_extension(&self, extension: ExtensionConfig) -> ExtensionResult<()> { match &extension { ExtensionConfig::Frontend { @@ -936,6 +1029,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( @@ -1321,6 +1415,35 @@ impl Agent { .context("Failed to persist provider config to session") } + /// Restore the provider from session data or fall back to global config + /// This is used when resuming a session to restore the provider state + pub async fn restore_provider_from_session(&self, session: &Session) -> Result<()> { + let config = Config::global(); + + let provider_name = session + .provider_name + .clone() + .or_else(|| config.get_goose_provider().ok()) + .ok_or_else(|| anyhow!("Could not configure agent: missing provider"))?; + + let model_config = match session.model_config.clone() { + Some(saved_config) => saved_config, + None => { + let model_name = config + .get_goose_model() + .map_err(|_| anyhow!("Could not configure agent: missing model"))?; + crate::model::ModelConfig::new(&model_name) + .map_err(|e| anyhow!("Could not configure agent: invalid model {}", e))? + } + }; + + let provider = crate::providers::create(&provider_name, model_config) + .await + .map_err(|e| anyhow!("Could not create provider: {}", e))?; + + self.update_provider(provider, &session.id).await + } + /// Override the system prompt with a custom template pub async fn override_system_prompt(&self, template: String) { let mut prompt_manager = self.prompt_manager.lock().await; diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index fe2ba045b16b..bc071ecfa3c0 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 { @@ -153,7 +153,7 @@ fn generate_extension_name( let base = server_info .and_then(|info| { let name = info.server_info.name.as_str(); - (!name.is_empty()).then(|| normalize(name.to_string())) + (!name.is_empty()).then(|| normalize(name)) }) .unwrap_or_else(|| "unnamed".to_string()); @@ -219,6 +219,7 @@ async fn child_process_client( mut command: Command, timeout: &Option, provider: SharedProvider, + working_dir: Option<&PathBuf>, ) -> ExtensionResult { #[cfg(unix)] command.process_group(0); @@ -228,6 +229,27 @@ async fn child_process_client( command.env("PATH", path); } + // Use explicitly passed working_dir, falling back to GOOSE_WORKING_DIR env var + let effective_working_dir = working_dir + .map(|p| p.to_path_buf()) + .or_else(|| std::env::var("GOOSE_WORKING_DIR").ok().map(PathBuf::from)); + + if let Some(ref dir) = effective_working_dir { + if dir.exists() && dir.is_dir() { + tracing::info!("Setting MCP process working directory: {:?}", dir); + command.current_dir(dir); + // Also set GOOSE_WORKING_DIR env var for the child process + command.env("GOOSE_WORKING_DIR", dir); + } else { + tracing::warn!( + "Working directory doesn't exist or isn't a directory: {:?}", + dir + ); + } + } else { + tracing::info!("No working directory specified, using default"); + } + let (transport, mut stderr) = TokioChildProcess::builder(command) .stderr(Stdio::piped()) .spawn()?; @@ -422,25 +444,6 @@ async fn create_streamable_http_client( } } -async fn create_stdio_client( - cmd: &str, - args: &[String], - all_envs: HashMap, - timeout: &Option, - provider: SharedProvider, -) -> ExtensionResult> { - extension_malware_check::deny_if_malicious_cmd_args(cmd, args).await?; - - let resolved_cmd = resolve_command(cmd); - let command = Command::new(resolved_cmd).configure(|command| { - command.args(args).envs(all_envs); - }); - - Ok(Box::new( - child_process_client(command, timeout, provider).await?, - )) -} - impl ExtensionManager { pub fn new(provider: SharedProvider) -> Self { Self { @@ -466,6 +469,22 @@ impl ExtensionManager { self.context.lock().await.clone() } + /// Resolve the working directory for an extension. + /// Priority: session working_dir > current_dir + async fn resolve_working_dir(&self) -> PathBuf { + // Try to get working_dir from session via context + if let Some(ref session_id) = self.context.lock().await.session_id { + if let Ok(session) = + crate::session::SessionManager::get_session(session_id, false).await + { + return session.working_dir; + } + } + + // Fall back to current_dir + std::env::current_dir().unwrap_or_default() + } + pub async fn supports_resources(&self) -> bool { self.extensions .lock() @@ -476,12 +495,15 @@ impl ExtensionManager { pub async fn add_extension(&self, config: ExtensionConfig) -> ExtensionResult<()> { let config_name = config.key().to_string(); - let sanitized_name = normalize(config_name.clone()); + let sanitized_name = normalize(&config_name); if self.extensions.lock().await.contains_key(&sanitized_name) { return Ok(()); } + // Resolve working_dir: session > current_dir + let effective_working_dir = self.resolve_working_dir().await; + let mut temp_dir = None; let client: Box = match &config { @@ -519,7 +541,24 @@ impl ExtensionManager { .. } => { let all_envs = merge_environments(envs, env_keys, &sanitized_name).await?; - create_stdio_client(cmd, args, all_envs, timeout, self.provider.clone()).await? + + // Check for malicious packages before launching the process + extension_malware_check::deny_if_malicious_cmd_args(cmd, args).await?; + + let cmd = resolve_command(cmd); + + let command = Command::new(cmd).configure(|command| { + command.args(args).envs(all_envs); + }); + + let client = child_process_client( + command, + timeout, + self.provider.clone(), + Some(&effective_working_dir), + ) + .await?; + Box::new(client) } ExtensionConfig::Builtin { name, timeout, .. } => { let cmd = std::env::current_exe() @@ -540,10 +579,17 @@ impl ExtensionManager { let command = Command::new(cmd).configure(|command| { command.arg("mcp").arg(name); }); - Box::new(child_process_client(command, timeout, self.provider.clone()).await?) + let client = child_process_client( + command, + timeout, + self.provider.clone(), + Some(&effective_working_dir), + ) + .await?; + Box::new(client) } ExtensionConfig::Platform { name, .. } => { - let normalized_key = normalize(name.clone()); + let normalized_key = normalize(name); let def = PLATFORM_EXTENSIONS .get(normalized_key.as_str()) .ok_or_else(|| { @@ -572,7 +618,15 @@ impl ExtensionManager { command.arg("python").arg(file_path.to_str().unwrap()); }); - Box::new(child_process_client(command, timeout, self.provider.clone()).await?) + let client = child_process_client( + command, + timeout, + self.provider.clone(), + Some(&effective_working_dir), + ) + .await?; + + Box::new(client) } ExtensionConfig::Frontend { .. } => { return Err(ExtensionError::ConfigError( @@ -630,7 +684,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(()) } @@ -1247,10 +1301,14 @@ 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 { // Use minute-level granularity to prevent conversation changes every second let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:00").to_string(); - let mut content = format!("\nIt is currently {}\n", timestamp); + let mut content = format!( + "\nIt is currently {}\nWorking directory: {}\n", + timestamp, + working_dir.display() + ); let platform_clients: Vec<(String, McpClientBox)> = { let extensions = self.extensions.lock().await; @@ -1308,7 +1366,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()), @@ -1760,8 +1818,9 @@ mod tests { #[tokio::test] async fn test_collect_moim_uses_minute_granularity() { let em = ExtensionManager::new_without_provider(); + let working_dir = std::path::Path::new("/tmp"); - if let Some(moim) = em.collect_moim().await { + if let Some(moim) = em.collect_moim(working_dir).await { // Timestamp should end with :00 (seconds fixed to 00) assert!( moim.contains(":00\n"), diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 0384990594eb..badece6751ae 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -24,10 +24,10 @@ pub(crate) mod todo_extension; mod tool_execution; pub mod types; -pub use agent::{Agent, AgentEvent}; +pub use agent::{Agent, AgentEvent, ExtensionLoadResult}; pub use execute_commands::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}; diff --git a/crates/goose/src/agents/moim.rs b/crates/goose/src/agents/moim.rs index 97f273d52412..2cef90d5b9a5 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"), @@ -135,7 +143,7 @@ mod tests { ), ]); - 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); diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index d539388d16ed..f7091b80cc63 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -209,6 +209,45 @@ } } }, + "/agent/restart": { + "post": { + "tags": [ + "super::routes::agent" + ], + "operationId": "restart_agent", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestartAgentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Agent restarted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestartAgentResponse" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid secret key" + }, + "404": { + "description": "Session not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/agent/resume": { "post": { "tags": [ @@ -231,7 +270,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/ResumeAgentResponse" } } } @@ -418,6 +457,41 @@ } } }, + "/agent/update_working_dir": { + "post": { + "tags": [ + "super::routes::agent" + ], + "operationId": "update_working_dir", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkingDirRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Working directory updated and agent restarted successfully" + }, + "400": { + "description": "Bad request - invalid directory path" + }, + "401": { + "description": "Unauthorized - invalid secret key" + }, + "404": { + "description": "Session not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/config": { "get": { "tags": [ @@ -2272,6 +2346,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": [ @@ -3535,6 +3654,25 @@ } ] }, + "ExtensionLoadResult": { + "type": "object", + "required": [ + "name", + "success" + ], + "properties": { + "error": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, "ExtensionQuery": { "type": "object", "required": [ @@ -4920,6 +5058,31 @@ } } }, + "RestartAgentRequest": { + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "session_id": { + "type": "string" + } + } + }, + "RestartAgentResponse": { + "type": "object", + "required": [ + "extension_results" + ], + "properties": { + "extension_results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionLoadResult" + } + } + } + }, "ResumeAgentRequest": { "type": "object", "required": [ @@ -4935,6 +5098,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", @@ -5275,6 +5456,20 @@ } } }, + "SessionExtensionsResponse": { + "type": "object", + "required": [ + "extensions" + ], + "properties": { + "extensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionConfig" + } + } + } + }, "SessionInsights": { "type": "object", "required": [ @@ -5431,6 +5626,13 @@ "working_dir" ], "properties": { + "extension_overrides": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionConfig" + }, + "nullable": true + }, "recipe": { "allOf": [ { @@ -5974,6 +6176,21 @@ } } }, + "UpdateWorkingDirRequest": { + "type": "object", + "required": [ + "session_id", + "working_dir" + ], + "properties": { + "session_id": { + "type": "string" + }, + "working_dir": { + "type": "string" + } + } + }, "UpsertConfigQuery": { "type": "object", "required": [ diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 927960cdc1ef..5c1a575decbe 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -42,6 +42,7 @@ import { View, ViewOptions } from './utils/navigationUtils'; import { useNavigation } from './hooks/useNavigation'; import { errorMessage } from './utils/conversionUtils'; +import { getInitialWorkingDir } from './utils/workingDir'; import { usePageViewTracking } from './hooks/useAnalytics'; import { trackOnboardingCompleted, trackErrorWithContext } from './utils/analytics'; @@ -53,82 +54,74 @@ function PageViewTracker() { // Route Components const HubRouteWrapper = () => { const setView = useNavigation(); - 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 [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + const routeState = (location.state as PairRouteState) || {}; + const [searchParams] = useSearchParams(); + const [isCreatingSession, setIsCreatingSession] = useState(false); - // Capture initialMessage in local state to survive route state being cleared by setSearchParams + // Capture initialMessage in local state to survive route state being cleared const [capturedInitialMessage, setCapturedInitialMessage] = useState( undefined ); - const [lastSessionId, setLastSessionId] = useState(undefined); - const [isCreatingSession, setIsCreatingSession] = useState(false); 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; + // Session ID and initialMessage come from route state (Hub, fork) or URL params (refresh, deeplink) + const sessionIdFromState = routeState.resumeSessionId; + const sessionId = sessionIdFromState || resumeSessionId || chat.sessionId || undefined; // Use route state if available, otherwise use captured state const initialMessage = routeState.initialMessage || capturedInitialMessage; + // Capture initialMessage when it comes from route state useEffect(() => { + console.log( + '[PairRouteWrapper] capture effect:', + JSON.stringify({ + routeStateInitialMessage: routeState.initialMessage, + }) + ); if (routeState.initialMessage) { setCapturedInitialMessage(routeState.initialMessage); } }, [routeState.initialMessage]); + // Create session if we have an initialMessage, recipeId, or recipeDeeplink but no sessionId 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); (async () => { try { - const newSession = await createSession({ + 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; + navigate(`/pair?resumeSessionId=${newSession.id}`, { + replace: true, + state: { resumeSessionId: newSession.id, initialMessage }, }); - setActiveSessionId(newSession.id); } catch (error) { - console.error('[PairRouteWrapper] Failed to create session:', error); + console.error('Failed to create session:', error); trackErrorWithContext(error, { component: 'PairRouteWrapper', action: 'create_session', @@ -145,39 +138,38 @@ const PairRouteWrapper = ({ recipeDeeplinkFromConfig, sessionId, isCreatingSession, - setSearchParams, - setActiveSessionId, + extensionsList, + navigate, ]); - // 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]); - - // Update URL with session ID when on /pair route (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; + navigate(`/pair?resumeSessionId=${sessionId}`, { + replace: true, + state: { resumeSessionId: sessionIdFromState, initialMessage }, }); } - }, [sessionId, resumeSessionId, setSearchParams]); + }, [sessionId, resumeSessionId, navigate, sessionIdFromState, initialMessage]); - // Update active session state when session ID changes + // Clear captured initialMessage when session changes (to prevent re-sending on navigation) useEffect(() => { - if (sessionId && sessionId !== activeSessionId) { - setActiveSessionId(sessionId); + if (sessionId && capturedInitialMessage && sessionIdFromState) { + const timer = setTimeout(() => { + setCapturedInitialMessage(undefined); + }, 100); + return () => clearTimeout(timer); } - }, [sessionId, activeSessionId, setActiveSessionId]); + return undefined; + }, [sessionId, capturedInitialMessage, sessionIdFromState]); return ( - + ); }; @@ -377,9 +369,6 @@ export function AppInner() { recipe: null, }); - // Store the active session ID for navigation persistence - const [activeSessionId, setActiveSessionId] = useState(null); - const { addExtension } = useConfig(); useEffect(() => { @@ -436,9 +425,7 @@ export function AppInner() { if ((isMac ? event.metaKey : event.ctrlKey) && event.key === 'n') { event.preventDefault(); try { - const workingDir = window.appConfig?.get('GOOSE_WORKING_DIR'); - console.log(`Creating new chat window with working dir: ${workingDir}`); - window.electron.createChatWindow(undefined, workingDir as string); + window.electron.createChatWindow(undefined, getInitialWorkingDir()); } catch (error) { console.error('Error creating new window:', error); } @@ -541,11 +528,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); @@ -597,17 +594,7 @@ export function AppInner() { } > } /> - - } - /> + } /> } /> = Options2 & { /** @@ -63,6 +63,15 @@ export const agentRemoveExtension = (optio } }); +export const restartAgent = (options: Options) => (options.client ?? client).post({ + url: '/agent/restart', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + export const resumeAgent = (options: Options) => (options.client ?? client).post({ url: '/agent/resume', ...options, @@ -101,6 +110,15 @@ export const updateAgentProvider = (option } }); +export const updateWorkingDir = (options: Options) => (options.client ?? client).post({ + url: '/agent/update_working_dir', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + export const readAllConfig = (options?: Options) => (options?.client ?? client).get({ url: '/config', ...options }); export const backupConfig = (options?: Options) => (options?.client ?? client).post({ url: '/config/backup', ...options }); @@ -395,6 +413,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 4c8ff01c4362..e6f6d6745585 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -335,6 +335,12 @@ export type ExtensionEntry = ExtensionConfig & { enabled: boolean; }; +export type ExtensionLoadResult = { + error?: string | null; + name: string; + success: boolean; +}; + export type ExtensionQuery = { config: ExtensionConfig; enabled: boolean; @@ -776,11 +782,24 @@ export type Response = { json_schema?: unknown; }; +export type RestartAgentRequest = { + session_id: string; +}; + +export type RestartAgentResponse = { + extension_results: Array; +}; + 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 */ @@ -887,6 +906,10 @@ export type SessionDisplayInfo = { workingDir: string; }; +export type SessionExtensionsResponse = { + extensions: Array; +}; + export type SessionInsights = { totalSessions: number; totalTokens: number; @@ -937,6 +960,7 @@ export type SlashCommandsResponse = { }; export type StartAgentRequest = { + extension_overrides?: Array | null; recipe?: Recipe | null; recipe_deeplink?: string | null; recipe_id?: string | null; @@ -1144,6 +1168,11 @@ export type UpdateSessionUserRecipeValuesResponse = { recipe: Recipe; }; +export type UpdateWorkingDirRequest = { + session_id: string; + working_dir: string; +}; + export type UpsertConfigQuery = { is_secret: boolean; key: string; @@ -1311,6 +1340,37 @@ export type AgentRemoveExtensionResponses = { export type AgentRemoveExtensionResponse = AgentRemoveExtensionResponses[keyof AgentRemoveExtensionResponses]; +export type RestartAgentData = { + body: RestartAgentRequest; + path?: never; + query?: never; + url: '/agent/restart'; +}; + +export type RestartAgentErrors = { + /** + * Unauthorized - invalid secret key + */ + 401: unknown; + /** + * Session not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type RestartAgentResponses = { + /** + * Agent restarted successfully + */ + 200: RestartAgentResponse; +}; + +export type RestartAgentResponse2 = RestartAgentResponses[keyof RestartAgentResponses]; + export type ResumeAgentData = { body: ResumeAgentRequest; path?: never; @@ -1337,10 +1397,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; @@ -1473,6 +1533,39 @@ export type UpdateAgentProviderResponses = { 200: unknown; }; +export type UpdateWorkingDirData = { + body: UpdateWorkingDirRequest; + path?: never; + query?: never; + url: '/agent/update_working_dir'; +}; + +export type UpdateWorkingDirErrors = { + /** + * Bad request - invalid directory path + */ + 400: unknown; + /** + * Unauthorized - invalid secret key + */ + 401: unknown; + /** + * Session not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type UpdateWorkingDirResponses = { + /** + * Working directory updated and agent restarted successfully + */ + 200: unknown; +}; + export type ReadAllConfigData = { body?: never; path?: never; @@ -2916,6 +3009,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/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 7fd02ba19870..67e38830f729 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -36,6 +36,9 @@ import { substituteParameters } from '../utils/providerUtils'; import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal'; import { toastSuccess } from '../toasts'; import { Recipe } from '../recipe'; +import { createSession } from '../sessions'; +import { getInitialWorkingDir } from '../utils/workingDir'; +import { useConfig } from './ConfigContext'; // Context for sharing current model info const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null); @@ -66,11 +69,13 @@ function BaseChatContent({ const navigate = useNavigate(); const [searchParams] = useSearchParams(); const scrollRef = useRef(null); + const { extensionsList } = useConfig(); const disableAnimation = location.state?.disableAnimation || false; const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false); const [hasNotAcceptedRecipe, setHasNotAcceptedRecipe] = useState(); const [hasRecipeSecurityWarnings, setHasRecipeSecurityWarnings] = useState(false); + const [isCreatingSession, setIsCreatingSession] = useState(false); const isMobile = useIsMobile(); const { state: sidebarState } = useSidebar(); @@ -95,6 +100,7 @@ function BaseChatContent({ session, messages, chatState, + setChatState, handleSubmit, submitElicitationResponse, stopStreaming, @@ -131,20 +137,40 @@ function BaseChatContent({ const shouldStartAgent = searchParams.get('shouldStartAgent') === 'true'; if (initialMessage) { - // Submit the initial message (e.g., from fork) 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) { - // Trigger agent to continue with existing conversation hasAutoSubmittedRef.current = true; handleSubmit(''); } - }, [session, initialMessage, searchParams, handleSubmit]); + }, [session, initialMessage, searchParams, handleSubmit, navigate, location]); - const handleFormSubmit = (e: React.FormEvent) => { + const handleFormSubmit = async (e: React.FormEvent) => { const customEvent = e as unknown as CustomEvent; const textValue = customEvent.detail?.value || ''; + // If no session exists, create one and navigate with the initial message + if (!session && !sessionId && textValue.trim() && !isCreatingSession) { + setIsCreatingSession(true); + try { + const newSession = await createSession(getInitialWorkingDir(), { + allExtensions: extensionsList, + }); + navigate(`/pair?resumeSessionId=${newSession.id}`, { + replace: true, + state: { resumeSessionId: newSession.id, initialMessage: textValue }, + }); + } catch { + setIsCreatingSession(false); + } + return; + } + if (recipe && textValue.trim()) { setHasStartedUsingRecipe(true); } @@ -284,8 +310,7 @@ function BaseChatContent({ : recipe.prompt; } - const initialPrompt = - (initialMessage && !hasAutoSubmittedRef.current ? initialMessage : '') || recipePrompt; + const initialPrompt = recipePrompt; if (sessionLoadError) { return ( @@ -402,6 +427,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 4c6c48e8ef9e..ec3e63d7f0b9 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -27,9 +27,10 @@ import { Recipe } from '../recipe'; import MessageQueue from './MessageQueue'; import { detectInterruption } from '../utils/interruptionDetector'; import { DiagnosticsModal } from './ui/DownloadDiagnostics'; -import { Message } from '../api'; +import { getSession, Message } from '../api'; import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal'; import CreateEditRecipeModal from './recipes/CreateEditRecipeModal'; +import { getInitialWorkingDir } from '../utils/workingDir'; import { trackFileAttached, trackVoiceDictation, @@ -73,6 +74,7 @@ interface ChatInputProps { sessionId: string | null; handleSubmit: (e: React.FormEvent) => void; chatState: ChatState; + setChatState?: (state: ChatState) => void; onStop?: () => void; commandHistory?: string[]; initialValue?: string; @@ -97,12 +99,14 @@ interface ChatInputProps { initialPrompt?: string; toolCount: number; append?: (message: Message) => void; + onWorkingDirChange?: (newDir: string) => void; } export default function ChatInput({ sessionId, handleSubmit, chatState = ChatState.Idle, + setChatState, onStop, commandHistory = [], initialValue = '', @@ -121,6 +125,7 @@ export default function ChatInput({ initialPrompt, toolCount, append: _append, + onWorkingDirChange, }: ChatInputProps) { const [_value, setValue] = useState(initialValue); const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback @@ -149,6 +154,26 @@ export default function ChatInput({ const [showCreateRecipeModal, setShowCreateRecipeModal] = useState(false); const [showEditRecipeModal, setShowEditRecipeModal] = useState(false); const [isFilePickerOpen, setIsFilePickerOpen] = useState(false); + const [sessionWorkingDir, setSessionWorkingDir] = useState(null); + + useEffect(() => { + if (!sessionId) { + return; + } + + const fetchSessionWorkingDir = async () => { + try { + const response = await getSession({ path: { session_id: sessionId } }); + if (response.data?.working_dir) { + setSessionWorkingDir(response.data.working_dir); + } + } catch (error) { + console.error('[ChatInput] Failed to fetch session working dir:', error); + } + }; + + fetchSessionWorkingDir(); + }, [sessionId]); // Save queue state (paused/interrupted) to storage useEffect(() => { @@ -1108,7 +1133,8 @@ export default function ChatInput({ isAnyImageLoading || isAnyDroppedFileLoading || isRecording || - isTranscribing; + isTranscribing || + chatState === ChatState.RestartingAgent; // Queue management functions - no storage persistence, only in-memory const handleRemoveQueuedMessage = (messageId: string) => { @@ -1359,7 +1385,9 @@ export default function ChatInput({ ? 'Recording...' : isTranscribing ? 'Transcribing...' - : 'Send'} + : chatState === ChatState.RestartingAgent + ? 'Restarting session...' + : 'Send'}

@@ -1499,8 +1527,19 @@ export default function ChatInput({ {/* Secondary actions and controls row below input */}
- {/* Directory path */} - + { + setSessionWorkingDir(newDir); + if (onWorkingDirChange) { + onWorkingDirChange(newDir); + } + }} + onRestartStart={() => setChatState?.(ChatState.RestartingAgent)} + onRestartEnd={() => setChatState?.(ChatState.Idle)} + />
@@ -1544,12 +1583,8 @@ export default function ChatInput({
- {sessionId && process.env.ALPHA && ( - <> -
- - - )} +
+ {sessionId && messages.length > 0 && ( <>
@@ -1619,6 +1654,7 @@ export default function ChatInput({ onSelectedIndexChange={(index) => setMentionPopover((prev) => ({ ...prev, selectedIndex: index })) } + workingDir={sessionWorkingDir ?? getInitialWorkingDir()} /> {sessionId && showCreateRecipeModal && ( diff --git a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx index e99bfb0584d9..b91b21adf015 100644 --- a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx +++ b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx @@ -1,6 +1,6 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; import { FileText, Clock, Home, Puzzle, History } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { SidebarContent, SidebarFooter, @@ -96,7 +96,16 @@ const menuItems: NavigationEntry[] = [ const AppSidebar: React.FC = ({ currentPath }) => { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const chatContext = useChatContext(); + const lastSessionIdRef = useRef(null); + const currentSessionId = currentPath === '/pair' ? searchParams.get('resumeSessionId') : null; + + useEffect(() => { + if (currentSessionId) { + lastSessionIdRef.current = currentSessionId; + } + }, [currentSessionId]); useEffect(() => { const timer = setTimeout(() => { @@ -130,6 +139,17 @@ const AppSidebar: React.FC = ({ currentPath }) => { return currentPath === path; }; + const handleNavigation = (path: string) => { + // For /pair, preserve the current session if one exists + // Priority: current URL param > last known session > context + const sessionId = currentSessionId || lastSessionIdRef.current || chatContext?.chat?.sessionId; + if (path === '/pair' && sessionId && sessionId.length > 0) { + navigate(`/pair?resumeSessionId=${sessionId}`); + } else { + navigate(path); + } + }; + const renderMenuItem = (entry: NavigationEntry, index: number) => { if (entry.type === 'separator') { return ; @@ -144,7 +164,7 @@ const AppSidebar: React.FC = ({ currentPath }) => { navigate(entry.path)} + onClick={() => handleNavigation(entry.path)} isActive={isActivePath(entry.path)} tooltip={entry.tooltip} className="w-full justify-start px-3 rounded-lg h-fit hover:bg-background-medium/50 transition-all duration-200 data-[active=true]:bg-background-medium" diff --git a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx index 3eee2d6a9dcf..47ca1f89d48b 100644 --- a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx +++ b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx @@ -5,6 +5,8 @@ import { Button } from './ui/button'; 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; @@ -91,46 +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/Hub.tsx b/ui/desktop/src/components/Hub.tsx index cb792e6b962c..f4528769aed5 100644 --- a/ui/desktop/src/components/Hub.tsx +++ b/ui/desktop/src/components/Hub.tsx @@ -7,45 +7,81 @@ * 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 { startNewSession } 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, }: { setView: (view: View, viewOptions?: ViewOptions) => void; }) { + 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()) { - await startNewSession(combinedTextFromInput, setView); + if (combinedTextFromInput.trim() && !isCreatingSession) { + const extensionConfigs = getExtensionConfigsWithOverrides(extensionsList); + clearExtensionOverrides(); + setIsCreatingSession(true); + + try { + const session = await createSession(workingDir, { + extensionConfigs, + allExtensions: extensionConfigs.length > 0 ? undefined : extensionsList, + }); + + setView('pair', { + disableAnimation: true, + resumeSessionId: session.id, + initialMessage: combinedTextFromInput, + }); + } catch (error) { + console.error('Failed to create session:', error); + setIsCreatingSession(false); + } + e.preventDefault(); } }; return (
-
+
+ {isCreatingSession && ( +
+ +
+ )}
{}} initialValue="" setView={setView} @@ -58,6 +94,7 @@ export default function Hub({ disableAnimation={false} sessionCosts={undefined} toolCount={0} + onWorkingDirChange={setWorkingDir} />
); diff --git a/ui/desktop/src/components/LauncherView.tsx b/ui/desktop/src/components/LauncherView.tsx index 60b0ed3f7ebd..c601b560e189 100644 --- a/ui/desktop/src/components/LauncherView.tsx +++ b/ui/desktop/src/components/LauncherView.tsx @@ -1,4 +1,5 @@ import { useRef, useState } from 'react'; +import { getInitialWorkingDir } from '../utils/workingDir'; export default function LauncherView() { const [query, setQuery] = useState(''); @@ -7,11 +8,8 @@ export default function LauncherView() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (query.trim()) { - // Create a new chat window with the query - const workingDir = window.appConfig?.get('GOOSE_WORKING_DIR') as string; - window.electron.createChatWindow(query, workingDir); + window.electron.createChatWindow(query, getInitialWorkingDir()); setQuery(''); - // Don't manually close - the blur handler will close the launcher when the new window takes focus } }; diff --git a/ui/desktop/src/components/Layout/AppLayout.tsx b/ui/desktop/src/components/Layout/AppLayout.tsx index 26029c48f63e..d801fa86ce6a 100644 --- a/ui/desktop/src/components/Layout/AppLayout.tsx +++ b/ui/desktop/src/components/Layout/AppLayout.tsx @@ -5,6 +5,7 @@ import { View, ViewOptions } from '../../utils/navigationUtils'; import { AppWindowMac, AppWindow } from 'lucide-react'; import { Button } from '../ui/button'; import { Sidebar, SidebarInset, SidebarProvider, SidebarTrigger, useSidebar } from '../ui/sidebar'; +import { getInitialWorkingDir } from '../../utils/workingDir'; const AppLayoutContent: React.FC = () => { const navigate = useNavigate(); @@ -66,10 +67,7 @@ const AppLayoutContent: React.FC = () => { }; const handleNewWindow = () => { - window.electron.createChatWindow( - undefined, - window.appConfig.get('GOOSE_WORKING_DIR') as string | undefined - ); + window.electron.createChatWindow(undefined, getInitialWorkingDir()); }; return ( diff --git a/ui/desktop/src/components/LoadingGoose.tsx b/ui/desktop/src/components/LoadingGoose.tsx index 56cddb27aa61..c4bbb1c929f0 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 session...', }; 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/MentionPopover.tsx b/ui/desktop/src/components/MentionPopover.tsx index 0220ab617dd8..a115ccfbb3e8 100644 --- a/ui/desktop/src/components/MentionPopover.tsx +++ b/ui/desktop/src/components/MentionPopover.tsx @@ -9,6 +9,7 @@ import { } from 'react'; import { ItemIcon } from './ItemIcon'; import { CommandType, getSlashCommands } from '../api'; +import { getInitialWorkingDir } from '../utils/workingDir'; type DisplayItemType = CommandType | 'Directory' | 'File'; @@ -41,6 +42,7 @@ interface MentionPopoverProps { isSlashCommand: boolean; selectedIndex: number; onSelectedIndexChange: (index: number) => void; + workingDir?: string; } // Enhanced fuzzy matching algorithm @@ -121,6 +123,7 @@ const MentionPopover = forwardRef< isSlashCommand, selectedIndex, onSelectedIndexChange, + workingDir, }, ref ) => { @@ -128,8 +131,7 @@ const MentionPopover = forwardRef< const [isLoading, setIsLoading] = useState(false); const popoverRef = useRef(null); const listRef = useRef(null); - - const currentWorkingDir = window.appConfig.get('GOOSE_WORKING_DIR') as string; + const currentWorkingDir = workingDir ?? getInitialWorkingDir(); const scanDirectoryFromRoot = useCallback( async (dirPath: string, relativePath = '', depth = 0): Promise => { diff --git a/ui/desktop/src/components/ParameterInputModal.tsx b/ui/desktop/src/components/ParameterInputModal.tsx index fec16b6d1deb..414835bb8e5a 100644 --- a/ui/desktop/src/components/ParameterInputModal.tsx +++ b/ui/desktop/src/components/ParameterInputModal.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Parameter } from '../recipe'; import { Button } from './ui/button'; +import { getInitialWorkingDir } from '../utils/workingDir'; interface ParameterInputModalProps { parameters: Parameter[]; @@ -72,16 +73,12 @@ const ParameterInputModal: React.FC = ({ const handleCancelOption = (option: 'new-chat' | 'back-to-form'): void => { if (option === 'new-chat') { - // Create a new chat window without recipe config try { - const workingDir = window.appConfig.get('GOOSE_WORKING_DIR'); - console.log(`Creating new chat window without recipe, working dir: ${workingDir}`); - window.electron.createChatWindow(undefined, workingDir as string); - // Close the current window after creating the new one + const workingDir = getInitialWorkingDir(); + window.electron.createChatWindow(undefined, workingDir); window.electron.hideWindow(); } catch (error) { console.error('Error creating new window:', error); - // Fallback: just close the modal onClose(); } } else { diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx index 05b0b11b782f..4cb334d3bdb7 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx @@ -1,25 +1,119 @@ -import { useCallback, 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'; 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 { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList'; +import { ExtensionConfig, getSessionExtensions } from '../../api'; +import { addToAgent, removeFromAgent } from '../settings/extensions/agent-api'; +import { + setExtensionOverride, + getExtensionOverride, + getExtensionOverrides, +} from '../../store/extensionOverrides'; interface BottomMenuExtensionSelectionProps { - sessionId: string; + sessionId: string | null; } 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); + const [isTransitioning, setIsTransitioning] = useState(false); + const [pendingSort, setPendingSort] = useState(false); + const [togglingExtension, setTogglingExtension] = useState(null); + const [refreshTrigger, setRefreshTrigger] = useState(0); + const sortTimeoutRef = useRef | null>(null); + const { extensionsList: allExtensions } = useConfig(); + const isHubView = !sessionId; + + useEffect(() => { + const handleSessionLoaded = () => { + setTimeout(() => { + setRefreshTrigger((prev) => prev + 1); + }, 500); + }; + + window.addEventListener('session-created', handleSessionLoaded); + window.addEventListener('message-stream-finished', handleSessionLoaded); + + return () => { + window.removeEventListener('session-created', handleSessionLoaded); + window.removeEventListener('message-stream-finished', handleSessionLoaded); + }; + }, []); + + useEffect(() => { + return () => { + if (sortTimeoutRef.current) { + clearTimeout(sortTimeoutRef.current); + } + }; + }, []); + + // Fetch session-specific extensions or use global defaults + useEffect(() => { + const fetchExtensions = async () => { + if (!sessionId) { + 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, refreshTrigger]); const handleToggle = useCallback( async (extensionConfig: FixedExtensionEntry) => { + if (togglingExtension === extensionConfig.name) { + return; + } + + setIsTransitioning(true); + setTogglingExtension(extensionConfig.name); + + if (isHubView) { + const currentState = getExtensionOverride(extensionConfig.name) ?? extensionConfig.enabled; + setExtensionOverride(extensionConfig.name, !currentState); + setPendingSort(true); + + if (sortTimeoutRef.current) { + clearTimeout(sortTimeoutRef.current); + } + + // Delay the re-sort to allow animation + sortTimeoutRef.current = setTimeout(() => { + setHubUpdateTrigger((prev) => prev + 1); + setPendingSort(false); + setIsTransitioning(false); + setTogglingExtension(null); + }, 800); + + toastService.success({ + title: 'Extension Updated', + msg: `${formatExtensionName(extensionConfig.name)} will be ${!currentState ? 'enabled' : 'disabled'} in new chats`, + }); + return; + } + if (!sessionId) { + setIsTransitioning(false); + setTogglingExtension(null); toastService.error({ title: 'Extension Toggle Error', msg: 'No active session found. Please start a chat session first.', @@ -29,26 +123,65 @@ 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, - }); - } catch (error) { - toastService.error({ - title: 'Extension Error', - msg: `Failed to ${extensionConfig.enabled ? 'disable' : 'enable'} ${extensionConfig.name}`, - traceback: error instanceof Error ? error.message : String(error), - }); + if (extensionConfig.enabled) { + await removeFromAgent(extensionConfig.name, sessionId, true); + } else { + await addToAgent(extensionConfig, sessionId, true); + } + + setPendingSort(true); + + 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); + setTogglingExtension(null); + }, 800); + } catch { + setIsTransitioning(false); + setPendingSort(false); + setTogglingExtension(null); } }, - [sessionId, addExtension] + [sessionId, isHubView, togglingExtension] ); + // Merge all available extensions with session-specific or hub override state + const extensionsList = useMemo(() => { + const hubOverrides = getExtensionOverrides(); + + if (isHubView) { + return allExtensions.map( + (ext) => + ({ + ...ext, + enabled: hubOverrides.has(ext.name) ? hubOverrides.get(ext.name)! : ext.enabled, + }) as FixedExtensionEntry + ); + } + + 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(); @@ -60,24 +193,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]); @@ -92,7 +212,13 @@ 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); + setTogglingExtension(null); } }} > @@ -105,7 +231,14 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS {activeCount} - + { + e.preventDefault(); + }} + >
+

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

-
+
{sortedExtensions.length === 0 ? (
{searchQuery ? 'no extensions found' : 'no extensions available'}
) : ( - 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} + > +
+ {formatExtensionName(ext.name)} +
+
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 8aa80e25aa4d..26c8dc399eae 100644 --- a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx +++ b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx @@ -1,23 +1,65 @@ import React, { useState } from 'react'; import { FolderDot } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip'; +import { updateWorkingDir } from '../../api'; +import { toast } from 'react-toastify'; interface DirSwitcherProps { - className?: string; + className: string; + sessionId: string | undefined; + workingDir: string; + onWorkingDirChange?: (newDir: string) => void; + onRestartStart?: () => void; + onRestartEnd?: () => void; } -export const DirSwitcher: React.FC = ({ className = '' }) => { +export const DirSwitcher: React.FC = ({ + className, + sessionId, + workingDir, + onWorkingDirChange, + onRestartStart, + onRestartEnd, +}) => { const [isTooltipOpen, setIsTooltipOpen] = useState(false); const [isDirectoryChooserOpen, setIsDirectoryChooserOpen] = useState(false); const handleDirectoryChange = async () => { if (isDirectoryChooserOpen) return; setIsDirectoryChooserOpen(true); + + let result; try { - await window.electron.directoryChooser(true); + result = await window.electron.directoryChooser(); } finally { setIsDirectoryChooserOpen(false); } + + if (result.canceled || result.filePaths.length === 0) { + return; + } + + const newDir = result.filePaths[0]; + + window.electron.addRecentDir(newDir); + + if (sessionId) { + onWorkingDirChange?.(newDir); + onRestartStart?.(); + + try { + await updateWorkingDir({ + body: { session_id: sessionId, working_dir: newDir }, + }); + } catch (error) { + console.error('[DirSwitcher] Failed to update working directory:', error); + toast.error('Failed to update working directory'); + } finally { + onRestartEnd?.(); + } + } else { + onWorkingDirChange?.(newDir); + } }; const handleDirectoryClick = async (event: React.MouseEvent) => { @@ -31,7 +73,6 @@ export const DirSwitcher: React.FC = ({ className = '' }) => { if (isCmdOrCtrlClick) { event.preventDefault(); event.stopPropagation(); - const workingDir = window.appConfig.get('GOOSE_WORKING_DIR') as string; await window.electron.openDirectoryInExplorer(workingDir); } else { await handleDirectoryChange(); @@ -53,14 +94,10 @@ export const DirSwitcher: React.FC = ({ className = '' }) => { disabled={isDirectoryChooserOpen} > -
- {String(window.appConfig.get('GOOSE_WORKING_DIR'))} -
+
{workingDir}
- - {window.appConfig.get('GOOSE_WORKING_DIR') as string} - + {workingDir} ); diff --git a/ui/desktop/src/components/extensions/ExtensionsView.tsx b/ui/desktop/src/components/extensions/ExtensionsView.tsx index 405744f5389e..061330a29ee0 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'; import { getSearchShortcutText } from '../../utils/keyboardShortcuts'; @@ -35,8 +34,6 @@ export default function ExtensionsView({ const [refreshKey, setRefreshKey] = useState(0); const [searchTerm, setSearchTerm] = useState(''); const { addExtension } = useConfig(); - const chatContext = useChatContext(); - const sessionId = chatContext?.chat.sessionId; // Only trigger refresh when deep link config changes AND we don't need to show env vars useEffect(() => { @@ -80,7 +77,10 @@ export default function ExtensionsView({ const extensionConfig = createExtensionConfig(formData); try { - await activateExtension(extensionConfig, addExtension, sessionId); + await activateExtensionDefault({ + addToConfig: addExtension, + extensionConfig: extensionConfig, + }); // Trigger a refresh of the extensions list setRefreshKey((prevKey) => prevKey + 1); } catch (error) { @@ -100,11 +100,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.{' '} {getSearchShortcutText()} to search.

+

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

{/* Action Buttons */}
@@ -134,7 +138,6 @@ export default function ExtensionsView({ setSearchTerm(term)} placeholder="Search extensions..."> formatExtensionName(ext.name)); + } catch { + return []; + } +} + interface EditSessionModalProps { session: Session | null; isOpen: boolean; @@ -49,7 +67,6 @@ const EditSessionModal = React.memo( if (session && isOpen) { setDescription(session.name); } else if (!isOpen) { - // Reset state when modal closes setDescription(''); setIsUpdating(false); } @@ -72,8 +89,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'); @@ -548,6 +563,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/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); diff --git a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx index 11d2aa6e2162..ef3b577ce9a3 100644 --- a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback, useMemo } from 'react'; import { Button } from '../../ui/button'; -import { Plus, AlertTriangle } from 'lucide-react'; +import { Plus } from 'lucide-react'; import { GPSIcon } from '../../ui/icons'; import { useConfig, FixedExtensionEntry } from '../../ConfigContext'; import ExtensionList from './subcomponents/ExtensionList'; @@ -12,11 +12,10 @@ import { getDefaultFormData, } from './utils'; -import { activateExtension, deleteExtension, toggleExtension, updateExtension } from './index'; -import { ExtensionConfig } from '../../../api'; +import { activateExtensionDefault, deleteExtension, toggleExtensionDefault } from './index'; +import { ExtensionConfig } from '../../../api/types.gen'; interface ExtensionSectionProps { - sessionId?: string; deepLinkConfig?: ExtensionConfig; showEnvVars?: boolean; hideButtons?: boolean; @@ -28,7 +27,6 @@ interface ExtensionSectionProps { } export default function ExtensionsSection({ - sessionId, deepLinkConfig, showEnvVars, hideButtons, @@ -38,8 +36,7 @@ export default function ExtensionsSection({ onModalClose, searchTerm = '', }: ExtensionSectionProps) { - const { getExtensions, addExtension, removeExtension, extensionsList, extensionWarnings } = - useConfig(); + const { getExtensions, addExtension, removeExtension, extensionsList } = useConfig(); const [selectedExtension, setSelectedExtension] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false); @@ -49,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 []; @@ -103,21 +87,12 @@ export default function ExtensionsSection({ return true; } - // If extension is enabled, we are trying to toggle if off, otherwise on const toggleDirection = extensionConfig.enabled ? 'toggleOff' : 'toggleOn'; - await toggleExtension({ + await toggleExtensionDefault({ toggle: toggleDirection, extensionConfig: extensionConfig, addToConfig: addExtension, - toastOptions: { silent: false }, - sessionId, - }); - - setPendingActivationExtensions((prev) => { - const updated = new Set(prev); - updated.delete(extensionConfig.name); - return updated; }); await fetchExtensions(); @@ -135,22 +110,12 @@ export default function ExtensionsSection({ const extensionConfig = createExtensionConfig(formData); try { - await activateExtension(extensionConfig, addExtension, sessionId); - setPendingActivationExtensions((prev) => { - const updated = new Set(prev); - updated.delete(extensionConfig.name); - return updated; + await activateExtensionDefault({ + addToConfig: addExtension, + extensionConfig: extensionConfig, }); } 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) { @@ -174,42 +139,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, - 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(); } }; @@ -231,29 +182,12 @@ export default function ExtensionsSection({ return (
- {/* Unsupported extension warnings */} - {extensionWarnings.length > 0 && ( -
-
- -
- {extensionWarnings.map((warning, index) => ( -

0 ? 'mt-1' : ''}> - {warning} -

- ))} -
-
-
- )} - {!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 867e0d6c0057..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,385 +12,97 @@ function isBuiltinExtension(config: ExtensionConfig): boolean { return config.type === 'builtin'; } -type AddExtension = (name: string, config: ExtensionConfig, enabled: boolean) => Promise; - -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 config and if a session is set, to the agent - */ -export async function activateExtension( - extensionConfig: ExtensionConfig, - addExtension: AddExtension, - sessionId?: string -) { - const isBuiltin = isBuiltinExtension(extensionConfig); - - if (sessionId) { - try { - await addToAgent(extensionConfig, sessionId, true); - } catch (error) { - console.error('Failed to add extension to agent:', error); - await addExtension(extensionConfig.name, extensionConfig, false); - trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin); - throw error; - } - } - - try { - await addExtension(extensionConfig.name, extensionConfig, true); - trackExtensionAdded(extensionConfig.name, true, undefined, isBuiltin); - } catch (error) { - console.error('Failed to add extension to config:', error); - if (sessionId) { - 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); - 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; +interface DeleteExtensionProps { + name: string; removeFromConfig: (name: string) => Promise; - extensionConfig: ExtensionConfig; - originalName?: string; - sessionId?: string; + extensionConfig?: ExtensionConfig; } /** - * Updates an extension configuration, handling name changes + * Deletes an extension from config (will no longer be loaded in new sessions) */ -export async function updateExtension({ - enabled, - addToConfig, +export async function deleteExtension({ + name, removeFromConfig, extensionConfig, - 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 { - if (sessionId) { - 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 - } catch (error) { - console.error('Failed to remove old extension from config during rename:', error); - throw error; // This is more critical, so we throw - } - - // Create a copy of the extension config with the sanitized name - const sanitizedExtensionConfig = { - ...extensionConfig, - name: sanitizedNewName, - }; - - // Add new extension with sanitized name - if (enabled && sessionId) { - try { - await addToAgent(sanitizedExtensionConfig, sessionId, false); - } catch (error) { - console.error('[updateExtension]: Failed to add renamed extension to agent:', error); - throw error; - } - } - - // Add to config with sanitized name - 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 { - // Create a copy of the extension config with the sanitized name - 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); - // Failed to add to agent -- show that error to user and do not update the config file - 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; - } - - // show a toast that it was successfully updated - 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 disabled extension in config:', error); - throw error; - } +}: DeleteExtensionProps) { + const isBuiltin = extensionConfig ? isBuiltinExtension(extensionConfig) : false; - // show a toast that it was successfully updated - toastService.success({ - title: `Update extension`, - msg: `Successfully updated ${sanitizedNewName} extension`, - }); - } + try { + await removeFromConfig(name); + trackExtensionDeleted(name, true, undefined, isBuiltin); + } catch (error) { + console.error('Failed to remove extension from config:', error); + trackExtensionDeleted(name, false, getErrorType(error), isBuiltin); + throw error; } } -interface ToggleExtensionProps { +interface ToggleExtensionDefaultProps { 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({ +export async function toggleExtensionDefault({ toggle, extensionConfig, addToConfig, - toastOptions = {}, - sessionId, -}: ToggleExtensionProps) { +}: ToggleExtensionDefaultProps) { const isBuiltin = isBuiltinExtension(extensionConfig); + const enabled = toggle === 'toggleOn'; - // disabled to enabled - if (toggle == 'toggleOn') { - try { - // add to agent with toast options - if (sessionId) { - 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); + try { + await addToConfig(extensionConfig.name, extensionConfig, enabled); + if (enabled) { 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 { - if (sessionId) { - 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 { - if (sessionId) { - 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; + } else { + trackExtensionDisabled(extensionConfig.name, true, undefined, isBuiltin); } - - // 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); + toastService.success({ + title: extensionConfig.name, + msg: enabled ? 'Extension enabled in defaults' : 'Extension removed from defaults', + }); + } 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); - throw error; - } - - // If we had an error removing from agent but succeeded updating config, still throw the original error - if (agentRemoveError) { - throw agentRemoveError; } + toastService.error({ + title: extensionConfig.name, + msg: 'Failed to update extension default', + }); + throw error; } } -interface DeleteExtensionProps { - name: string; - removeFromConfig: (name: string) => Promise; - sessionId?: string; - extensionConfig?: ExtensionConfig; +interface ActivateExtensionDefaultProps { + addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; + extensionConfig: ExtensionConfig; } -/** - * Deletes an extension completely from both agent and config - */ -export async function deleteExtension({ - name, - removeFromConfig, - sessionId, +export async function activateExtensionDefault({ + addToConfig, extensionConfig, -}: DeleteExtensionProps) { - const isBuiltin = extensionConfig ? isBuiltinExtension(extensionConfig) : false; - - let agentRemoveError = null; - try { - if (sessionId) { - await removeFromAgent(name, sessionId, true); - } - } catch (error) { - console.error('Failed to remove extension from agent during deletion:', error); - agentRemoveError = error; - } +}: ActivateExtensionDefaultProps): Promise { + const isBuiltin = isBuiltinExtension(extensionConfig); try { - await removeFromConfig(name); - if (agentRemoveError) { - trackExtensionDeleted(name, false, getErrorType(agentRemoveError), isBuiltin); - } else { - trackExtensionDeleted(name, true, undefined, isBuiltin); - } + 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 remove extension from config after removing from agent. Error:', - error - ); - trackExtensionDeleted(name, false, getErrorType(error), isBuiltin); + 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; } - - if (agentRemoveError) { - throw agentRemoveError; - } } diff --git a/ui/desktop/src/components/settings/extensions/index.ts b/ui/desktop/src/components/settings/extensions/index.ts index 5469fc52ad69..67d0dc161df0 100644 --- a/ui/desktop/src/components/settings/extensions/index.ts +++ b/ui/desktop/src/components/settings/extensions/index.ts @@ -1,20 +1,13 @@ -// Export public API export { DEFAULT_EXTENSION_TIMEOUT, nameToKey } from './utils'; -// Export extension management functions export { - activateExtension, - addToAgentOnStartup, - updateExtension, - toggleExtension, + activateExtensionDefault, + 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 a2ab0b121fbf..378bc5c7fda6 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; @@ -55,7 +53,7 @@ export default function ExtensionList({

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

{sortedEnabledExtensions.map((extension) => ( @@ -65,7 +63,6 @@ export default function ExtensionList({ onToggle={onToggle} onConfigure={onConfigure} isStatic={isStatic} - isPendingActivation={pendingActivationExtensions.has(extension.name)} /> ))}
@@ -100,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, ''); } diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index 41580f7609ae..70e9fcbf125e 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(); @@ -33,6 +34,7 @@ interface UseChatStreamReturn { session?: Session; messages: Message[]; chatState: ChatState; + setChatState: (state: ChatState) => void; handleSubmit: (userMessage: string) => Promise; submitElicitationResponse: ( elicitationId: string, @@ -221,6 +223,7 @@ export function useChatStream({ accumulatedTotalTokens: cached.session?.accumulated_total_tokens ?? 0, }); setChatState(ChatState.Idle); + onSessionLoaded?.(); return; } @@ -246,16 +249,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?.(); @@ -507,6 +514,7 @@ export function useChatStream({ messages: maybe_cached_messages, session: maybe_cached_session, chatState, + setChatState, handleSubmit, submitElicitationResponse, stopStreaming, diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 85bcb31813a8..b10b937ebf3b 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -1189,9 +1189,17 @@ ipcMain.handle('open-external', async (_event, url: string) => { } }); -// Handle directory chooser -ipcMain.handle('directory-chooser', (_event) => { - return openDirectoryDialog(); +ipcMain.handle('directory-chooser', async () => { + return dialog.showOpenDialog({ + properties: ['openDirectory', 'createDirectory'], + defaultPath: os.homedir(), + }); +}); + +ipcMain.handle('add-recent-dir', (_event, dir: string) => { + if (dir) { + addRecentDir(dir); + } }); // Handle scheduling engine settings diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index b012691da077..043508a293be 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -61,7 +61,7 @@ type ElectronAPI = { reactReady: () => void; getConfig: () => Record; hideWindow: () => void; - directoryChooser: (replace?: boolean) => Promise; + directoryChooser: () => Promise; createChatWindow: ( query?: string, dir?: string, @@ -134,6 +134,7 @@ type ElectronAPI = { hasAcceptedRecipeBefore: (recipe: Recipe) => Promise; recordRecipeHash: (recipe: Recipe) => Promise; openDirectoryInExplorer: (directoryPath: string) => Promise; + addRecentDir: (dir: string) => Promise; }; type AppConfigAPI = { @@ -270,6 +271,7 @@ const electronAPI: ElectronAPI = { recordRecipeHash: (recipe: Recipe) => ipcRenderer.invoke('record-recipe-hash', recipe), openDirectoryInExplorer: (directoryPath: string) => ipcRenderer.invoke('open-directory-in-explorer', directoryPath), + addRecentDir: (dir: string) => ipcRenderer.invoke('add-recent-dir', dir), }; const appConfigAPI: AppConfigAPI = { diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index 5217dd0f42b0..1efec4d44c0d 100644 --- a/ui/desktop/src/sessions.ts +++ b/ui/desktop/src/sessions.ts @@ -1,5 +1,11 @@ -import { Session, startAgent } from './api'; +import { Session, startAgent, ExtensionConfig } from './api'; import type { setViewType } from './hooks/useNavigation'; +import { + getExtensionConfigsWithOverrides, + clearExtensionOverrides, + hasExtensionOverrides, +} from './store/extensionOverrides'; +import type { FixedExtensionEntry } from './components/ConfigContext'; export function resumeSession(session: Session, setView: setViewType) { setView('pair', { @@ -8,16 +14,22 @@ export function resumeSession(session: Session, setView: setViewType) { }); } -export async function createSession(options?: { - recipeId?: string; - recipeDeeplink?: string; -}): Promise { +export async function createSession( + workingDir: string, + options?: { + recipeId?: string; + recipeDeeplink?: string; + extensionConfigs?: ExtensionConfig[]; + allExtensions?: FixedExtensionEntry[]; + } +): Promise { const body: { working_dir: string; recipe_id?: string; recipe_deeplink?: string; + extension_overrides?: ExtensionConfig[]; } = { - working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string, + working_dir: workingDir, }; if (options?.recipeId) { @@ -26,22 +38,37 @@ export async function createSession(options?: { body.recipe_deeplink = options.recipeDeeplink; } + 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; + } + if (hasExtensionOverrides()) { + clearExtensionOverrides(); + } + } + const newAgent = await startAgent({ body, throwOnError: true, }); + return newAgent.data; } export async function startNewSession( + workingDir: string, initialText: string | undefined, setView: setViewType, options?: { recipeId?: string; recipeDeeplink?: string; + allExtensions?: FixedExtensionEntry[]; } ): Promise { - const session = await createSession(options); + const session = await createSession(workingDir, options); setView('pair', { disableAnimation: true, diff --git a/ui/desktop/src/store/extensionOverrides.ts b/ui/desktop/src/store/extensionOverrides.ts new file mode 100644 index 000000000000..5755126961a0 --- /dev/null +++ b/ui/desktop/src/store/extensionOverrides.ts @@ -0,0 +1,59 @@ +// Store for extension overrides when starting a new session from the hub +// These overrides allow temporarily enabling/disabling extensions before creating a session +// Resets after session creation + +import type { ExtensionConfig } from '../api'; + +// Map of extension name -> enabled state (overrides from hub view) +type ExtensionOverrides = Map; + +const state: { + extensionOverrides: ExtensionOverrides; +} = { + extensionOverrides: new Map(), +}; + +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(); +} + +export function getExtensionConfigsWithOverrides( + allExtensions: Array<{ name: string; enabled: boolean } & Omit> +): ExtensionConfig[] { + if (state.extensionOverrides.size === 0) { + return allExtensions + .filter((ext) => ext.enabled) + .map((ext) => { + const { enabled: _enabled, ...config } = ext; + return config as ExtensionConfig; + }); + } + + return allExtensions + .filter((ext) => { + if (state.extensionOverrides.has(ext.name)) { + return state.extensionOverrides.get(ext.name); + } + return ext.enabled; + }) + .map((ext) => { + const { enabled: _enabled, ...config } = ext; + return config as ExtensionConfig; + }); +} diff --git a/ui/desktop/src/toasts.tsx b/ui/desktop/src/toasts.tsx index 6e3754e26579..c6d19cd1efbf 100644 --- a/ui/desktop/src/toasts.tsx +++ b/ui/desktop/src/toasts.tsx @@ -8,6 +8,7 @@ import { GroupedExtensionLoadingToast, ExtensionLoadingStatus, } from './components/GroupedExtensionLoadingToast'; +import { getInitialWorkingDir } from './utils/workingDir'; export interface ToastServiceOptions { silent?: boolean; @@ -109,7 +110,7 @@ class ToastService { { ...commonToastOptions, toastId, - autoClose: false, + autoClose: isComplete ? 5000 : false, closeButton: true, closeOnClick: false, // Prevent closing when clicking to expand/collapse } @@ -195,7 +196,9 @@ function ToastErrorContent({
{showRecovery && ( - + )} {hasBoth && ( 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..369a64c82cec 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,43 @@ 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 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 { + if (!results || results.length === 0) { + return; + } + + const failedExtensions = results.filter((r) => !r.success); + + if (results.length === 1 && 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..aaa6ba67b1c6 100644 --- a/ui/desktop/src/utils/navigationUtils.ts +++ b/ui/desktop/src/utils/navigationUtils.ts @@ -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,7 +30,6 @@ export type ViewOptions = { parentViewOptions?: ViewOptions; disableAnimation?: boolean; initialMessage?: string; - resetChat?: boolean; shareToken?: string; resumeSessionId?: string; pendingScheduleDeepLink?: string; diff --git a/ui/desktop/src/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts index 5206339df9aa..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,95 +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; - - // SSE is unsupported - fail immediately without calling the backend - if (extensionConfig.type === 'sse') { - const errMsg = 'SSE is unsupported, migrate to streamable_http'; - extensionStatuses.set(extensionName, { - name: extensionName, - status: 'error', - error: errMsg, - recoverHints: createExtensionRecoverHints(errMsg), - }); - updateToast(); - return; - } - - 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; } }; diff --git a/ui/desktop/src/utils/workingDir.ts b/ui/desktop/src/utils/workingDir.ts new file mode 100644 index 000000000000..413e38f6cc87 --- /dev/null +++ b/ui/desktop/src/utils/workingDir.ts @@ -0,0 +1,3 @@ +export const getInitialWorkingDir = (): string => { + return (window.appConfig?.get('GOOSE_WORKING_DIR') as string) || ''; +};