diff --git a/crates/goose-server/src/commands/agent.rs b/crates/goose-server/src/commands/agent.rs index 5f47e392f3d7..b9ab64f41c35 100644 --- a/crates/goose-server/src/commands/agent.rs +++ b/crates/goose-server/src/commands/agent.rs @@ -33,7 +33,7 @@ pub async fn run() -> Result<()> { let new_agent = Agent::new(); let agent_ref = Arc::new(new_agent); - let app_state = state::AppState::new(agent_ref.clone(), secret_key.clone()).await; + let app_state = state::AppState::new(agent_ref.clone(), secret_key.clone()); let schedule_file_path = choose_app_strategy(APP_STRATEGY.clone())? .data_dir() diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index f3745c410f20..54f5f1903c00 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -370,6 +370,8 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::config_management::upsert_permissions, super::routes::config_management::create_custom_provider, super::routes::config_management::remove_custom_provider, + super::routes::agent::start_agent, + super::routes::agent::resume_agent, super::routes::agent::get_tools, super::routes::agent::add_sub_recipes, super::routes::agent::extend_prompt, @@ -486,6 +488,10 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::agent::UpdateProviderRequest, super::routes::agent::SessionConfigRequest, super::routes::agent::GetToolsQuery, + super::routes::agent::UpdateRouterToolSelectorRequest, + super::routes::agent::StartAgentRequest, + super::routes::agent::ResumeAgentRequest, + super::routes::agent::StartAgentResponse, super::routes::agent::ErrorResponse, )) )] diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 3bd0385fea93..2a5911826227 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -1,5 +1,6 @@ use super::utils::verify_secret_key; use crate::state::AppState; +use axum::response::IntoResponse; use axum::{ extract::{Query, State}, http::{HeaderMap, StatusCode}, @@ -7,20 +8,29 @@ use axum::{ Json, Router, }; use goose::config::PermissionManager; +use goose::conversation::message::Message; +use goose::conversation::Conversation; use goose::model::ModelConfig; use goose::providers::create; -use goose::recipe::Response; +use goose::recipe::{Recipe, Response}; +use goose::session; +use goose::session::SessionMetadata; use goose::{ agents::{extension::ToolInfo, extension_manager::get_parameter_names}, config::permission::PermissionLevel, }; use goose::{config::Config, recipe::SubRecipe}; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::atomic::Ordering; use std::sync::Arc; +use tracing::error; #[derive(Deserialize, utoipa::ToSchema)] pub struct ExtendPromptRequest { extension: String, + #[allow(dead_code)] + session_id: String, } #[derive(Serialize, utoipa::ToSchema)] @@ -31,6 +41,8 @@ pub struct ExtendPromptResponse { #[derive(Deserialize, utoipa::ToSchema)] pub struct AddSubRecipesRequest { sub_recipes: Vec, + #[allow(dead_code)] + session_id: String, } #[derive(Serialize, utoipa::ToSchema)] @@ -42,16 +54,47 @@ pub struct AddSubRecipesResponse { pub struct UpdateProviderRequest { provider: String, model: Option, + #[allow(dead_code)] + session_id: String, } #[derive(Deserialize, utoipa::ToSchema)] pub struct SessionConfigRequest { response: Option, + #[allow(dead_code)] + session_id: String, } #[derive(Deserialize, utoipa::ToSchema)] pub struct GetToolsQuery { extension_name: Option, + #[allow(dead_code)] + session_id: String, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct UpdateRouterToolSelectorRequest { + #[allow(dead_code)] + session_id: String, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct StartAgentRequest { + working_dir: String, + recipe: Option, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct ResumeAgentRequest { + session_id: String, +} + +// This is the same as SessionHistoryResponse +#[derive(Serialize, utoipa::ToSchema)] +pub struct StartAgentResponse { + session_id: String, + metadata: SessionMetadata, + messages: Vec, } #[derive(Serialize, utoipa::ToSchema)] @@ -59,6 +102,101 @@ pub struct ErrorResponse { error: String, } +#[utoipa::path( + post, + path = "/agent/start", + request_body = StartAgentRequest, + responses( + (status = 200, description = "Agent started successfully", body = StartAgentResponse), + (status = 400, description = "Bad request - invalid working directory"), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 500, description = "Internal server error") + ) +)] +async fn start_agent( + State(state): State>, + headers: HeaderMap, + Json(payload): Json, +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; + + state.reset().await; + + let session_id = session::generate_session_id(); + let counter = state.session_counter.fetch_add(1, Ordering::SeqCst) + 1; + + let metadata = SessionMetadata { + working_dir: PathBuf::from(&payload.working_dir), + description: format!("New session {}", counter), + schedule_id: None, + message_count: 0, + total_tokens: Some(0), + input_tokens: Some(0), + output_tokens: Some(0), + accumulated_total_tokens: Some(0), + accumulated_input_tokens: Some(0), + accumulated_output_tokens: Some(0), + extension_data: Default::default(), + recipe: payload.recipe, + }; + + let session_path = match session::get_path(session::Identifier::Name(session_id.clone())) { + Ok(path) => path, + Err(_) => return Err(StatusCode::BAD_REQUEST), + }; + + let conversation = Conversation::empty(); + session::storage::save_messages_with_metadata(&session_path, &metadata, &conversation) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(StartAgentResponse { + session_id, + metadata, + messages: conversation.messages().clone(), + })) +} + +#[utoipa::path( + post, + path = "/agent/resume", + request_body = ResumeAgentRequest, + responses( + (status = 200, description = "Agent started successfully", body = StartAgentResponse), + (status = 400, description = "Bad request - invalid working directory"), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 500, description = "Internal server error") + ) +)] +async fn resume_agent( + State(state): State>, + headers: HeaderMap, + Json(payload): Json, +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; + + let session_path = + match session::get_path(session::Identifier::Name(payload.session_id.clone())) { + Ok(path) => path, + Err(_) => return Err(StatusCode::BAD_REQUEST), + }; + + let metadata = session::read_metadata(&session_path).map_err(|_| StatusCode::NOT_FOUND)?; + + let conversation = match session::read_messages(&session_path) { + Ok(messages) => messages, + Err(e) => { + error!("Failed to read session messages: {:?}", e); + return Err(StatusCode::NOT_FOUND); + } + }; + + Ok(Json(StartAgentResponse { + session_id: payload.session_id.clone(), + metadata, + messages: conversation.messages().clone(), + })) +} + #[utoipa::path( post, path = "/agent/add_sub_recipes", @@ -76,10 +214,7 @@ async fn add_sub_recipes( ) -> Result, StatusCode> { verify_secret_key(&headers, &state)?; - let agent = state - .get_agent() - .await - .map_err(|_| StatusCode::PRECONDITION_FAILED)?; + let agent = state.get_agent().await; agent.add_sub_recipes(payload.sub_recipes.clone()).await; Ok(Json(AddSubRecipesResponse { success: true })) } @@ -101,10 +236,7 @@ async fn extend_prompt( ) -> Result, StatusCode> { verify_secret_key(&headers, &state)?; - let agent = state - .get_agent() - .await - .map_err(|_| StatusCode::PRECONDITION_FAILED)?; + let agent = state.get_agent().await; agent.extend_system_prompt(payload.extension.clone()).await; Ok(Json(ExtendPromptResponse { success: true })) } @@ -113,7 +245,8 @@ async fn extend_prompt( get, path = "/agent/tools", params( - ("extension_name" = Option, Query, description = "Optional extension name to filter tools") + ("extension_name" = Option, Query, description = "Optional extension name to filter tools"), + ("session_id" = String, Query, description = "Required session ID to scope tools to a specific session") ), responses( (status = 200, description = "Tools retrieved successfully", body = Vec), @@ -131,10 +264,7 @@ async fn get_tools( let config = Config::global(); let goose_mode = config.get_param("GOOSE_MODE").unwrap_or("auto".to_string()); - let agent = state - .get_agent() - .await - .map_err(|_| StatusCode::PRECONDITION_FAILED)?; + let agent = state.get_agent().await; let permission_manager = PermissionManager::default(); let mut tools: Vec = agent @@ -186,31 +316,37 @@ async fn update_agent_provider( State(state): State>, headers: HeaderMap, Json(payload): Json, -) -> Result { - verify_secret_key(&headers, &state)?; - - let agent = state - .get_agent() - .await - .map_err(|_e| StatusCode::PRECONDITION_FAILED)?; +) -> Result { + verify_secret_key(&headers, &state).map_err(|e| (e, String::new()))?; + let agent = state.get_agent().await; let config = Config::global(); let model = match payload .model .or_else(|| config.get_param("GOOSE_MODEL").ok()) { Some(m) => m, - None => return Err(StatusCode::BAD_REQUEST), + None => return Err((StatusCode::BAD_REQUEST, "No model specified".to_string())), }; - let model_config = ModelConfig::new(&model).map_err(|_| StatusCode::BAD_REQUEST)?; + let model_config = ModelConfig::new(&model).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("Invalid model config: {}", e), + ) + })?; + + let new_provider = create(&payload.provider, model_config).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("Failed to create provider: {}", e), + ) + })?; - let new_provider = - create(&payload.provider, model_config).map_err(|_| StatusCode::BAD_REQUEST)?; agent .update_provider(new_provider) .await - .map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|_e| (StatusCode::INTERNAL_SERVER_ERROR, String::new()))?; Ok(StatusCode::OK) } @@ -218,6 +354,7 @@ async fn update_agent_provider( #[utoipa::path( post, path = "/agent/update_router_tool_selector", + request_body = UpdateRouterToolSelectorRequest, responses( (status = 200, description = "Tool selection strategy updated successfully", body = String), (status = 401, description = "Unauthorized - invalid secret key"), @@ -228,6 +365,7 @@ async fn update_agent_provider( async fn update_router_tool_selector( State(state): State>, headers: HeaderMap, + Json(_payload): Json, ) -> Result, Json> { verify_secret_key(&headers, &state).map_err(|_| { Json(ErrorResponse { @@ -235,13 +373,7 @@ async fn update_router_tool_selector( }) })?; - let agent = state.get_agent().await.map_err(|e| { - tracing::error!("Failed to get agent: {}", e); - Json(ErrorResponse { - error: format!("Failed to get agent: {}", e), - }) - })?; - + let agent = state.get_agent().await; agent .update_router_tool_selector(None, Some(true)) .await @@ -279,13 +411,7 @@ async fn update_session_config( }) })?; - let agent = state.get_agent().await.map_err(|e| { - tracing::error!("Failed to get agent: {}", e); - Json(ErrorResponse { - error: format!("Failed to get agent: {}", e), - }) - })?; - + let agent = state.get_agent().await; if let Some(response) = payload.response { agent.add_final_output_tool(response).await; @@ -300,6 +426,8 @@ async fn update_session_config( pub fn routes(state: Arc) -> Router { Router::new() + .route("/agent/start", post(start_agent)) + .route("/agent/resume", post(resume_agent)) .route("/agent/prompt", post(extend_prompt)) .route("/agent/tools", get(get_tools)) .route("/agent/update_provider", post(update_agent_provider)) diff --git a/crates/goose-server/src/routes/audio.rs b/crates/goose-server/src/routes/audio.rs index 8b2046f2af50..3ab4d8b83dd7 100644 --- a/crates/goose-server/src/routes/audio.rs +++ b/crates/goose-server/src/routes/audio.rs @@ -413,8 +413,7 @@ mod tests { let state = AppState::new( Arc::new(goose::agents::Agent::new()), "test-secret".to_string(), - ) - .await; + ); let app = routes(state); // Test without auth header @@ -440,8 +439,7 @@ mod tests { let state = AppState::new( Arc::new(goose::agents::Agent::new()), "test-secret".to_string(), - ) - .await; + ); let app = routes(state); // Create a large base64 string (simulating > 25MB audio) @@ -470,8 +468,7 @@ mod tests { let state = AppState::new( Arc::new(goose::agents::Agent::new()), "test-secret".to_string(), - ) - .await; + ); let app = routes(state); let request = Request::builder() @@ -500,8 +497,7 @@ mod tests { let state = AppState::new( Arc::new(goose::agents::Agent::new()), "test-secret".to_string(), - ) - .await; + ); let app = routes(state); let request = Request::builder() diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index 570ae5bf349b..5c5b8bbb2f47 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -858,8 +858,7 @@ mod tests { let test_state = AppState::new( Arc::new(goose::agents::Agent::default()), "test".to_string(), - ) - .await; + ); let sched_storage_path = choose_app_strategy(APP_STRATEGY.clone()) .unwrap() .data_dir() diff --git a/crates/goose-server/src/routes/context.rs b/crates/goose-server/src/routes/context.rs index e64f3fa6b635..7f23b8777fd3 100644 --- a/crates/goose-server/src/routes/context.rs +++ b/crates/goose-server/src/routes/context.rs @@ -53,10 +53,7 @@ async fn manage_context( ) -> Result, StatusCode> { verify_secret_key(&headers, &state)?; - let agent = state - .get_agent() - .await - .map_err(|_| StatusCode::PRECONDITION_FAILED)?; + let agent = state.get_agent().await; let mut processed_messages = Conversation::new_unvalidated(vec![]); let mut token_counts: Vec = vec![]; diff --git a/crates/goose-server/src/routes/extension.rs b/crates/goose-server/src/routes/extension.rs index ddcea98ea6a0..1567fb71ecfc 100644 --- a/crates/goose-server/src/routes/extension.rs +++ b/crates/goose-server/src/routes/extension.rs @@ -271,11 +271,7 @@ async fn add_extension( }, }; - // Get a reference to the agent - let agent = state - .get_agent() - .await - .map_err(|_| StatusCode::PRECONDITION_FAILED)?; + let agent = state.get_agent().await; let response = agent.add_extension(extension_config).await; // Respond with the result. @@ -305,11 +301,7 @@ async fn remove_extension( ) -> Result, StatusCode> { verify_secret_key(&headers, &state)?; - // Get a reference to the agent - let agent = state - .get_agent() - .await - .map_err(|_| StatusCode::PRECONDITION_FAILED)?; + let agent = state.get_agent().await; match agent.remove_extension(&name).await { Ok(_) => Ok(Json(ExtensionResponse { error: false, diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index 93c8620de8f0..84b114b82f7d 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -116,16 +116,7 @@ async fn create_recipe( request.messages.len() ); - let error_response = CreateRecipeResponse { - recipe: None, - error: Some("Missing agent".to_string()), - }; - let agent = state.get_agent().await.map_err(|e| { - tracing::error!("Failed to get agent for recipe creation: {}", e); - (StatusCode::PRECONDITION_FAILED, Json(error_response)) - })?; - - tracing::debug!("Agent retrieved successfully, creating recipe from conversation"); + let agent = state.get_agent().await; // Create base recipe from agent state and messages let recipe_result = agent @@ -134,16 +125,12 @@ async fn create_recipe( match recipe_result { Ok(mut recipe) => { - tracing::info!("Recipe created successfully with title: '{}'", recipe.title); - - // Update with user-provided metadata recipe.title = request.title; recipe.description = request.description; if request.activities.is_some() { recipe.activities = request.activities }; - // Add author if provided if let Some(author_req) = request.author { recipe.author = Some(goose::recipe::Author { contact: author_req.contact, @@ -151,19 +138,13 @@ async fn create_recipe( }); } - tracing::debug!("Recipe metadata updated, returning success response"); - Ok(Json(CreateRecipeResponse { recipe: Some(recipe), error: None, })) } Err(e) => { - // Log the detailed error for debugging - tracing::error!("Recipe creation failed: {}", e); tracing::error!("Error details: {:?}", e); - - // Return 400 Bad Request with error message let error_message = format!("Recipe creation failed: {}", e); let error_response = CreateRecipeResponse { recipe: None, diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index 0b75a913777e..a11ce3134501 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -26,7 +26,6 @@ use serde_json::json; use serde_json::Value; use std::{ convert::Infallible, - path::PathBuf, pin::Pin, sync::Arc, task::{Context, Poll}, @@ -89,8 +88,6 @@ fn track_tool_telemetry(content: &MessageContent, all_messages: &[Message]) { struct ChatRequest { messages: Vec, session_id: Option, - session_working_dir: String, - scheduled_job_id: Option, recipe_name: Option, recipe_version: Option, } @@ -203,22 +200,42 @@ async fn reply_handler( let cancel_token = CancellationToken::new(); let messages = Conversation::new_unvalidated(request.messages); - let session_working_dir = request.session_working_dir.clone(); - let session_id = request - .session_id - .unwrap_or_else(session::generate_session_id); + let session_id = request.session_id.ok_or_else(|| { + tracing::error!("session_id is required but was not provided"); + StatusCode::BAD_REQUEST + })?; let task_cancel = cancel_token.clone(); let task_tx = tx.clone(); - std::mem::drop(tokio::spawn(async move { - let agent = match state.get_agent().await { - Ok(agent) => agent, - Err(_) => { + drop(tokio::spawn(async move { + let agent = state.get_agent().await; + + // Load session metadata to get the working directory and other config + let session_path = match session::get_path(session::Identifier::Name(session_id.clone())) { + Ok(path) => path, + Err(e) => { + tracing::error!("Failed to get session path for {}: {}", session_id, e); let _ = stream_event( MessageEvent::Error { - error: "No agent configured".to_string(), + error: format!("Failed to get session path: {}", e), + }, + &task_tx, + &cancel_token, + ) + .await; + return; + } + }; + + let session_metadata = match session::read_metadata(&session_path) { + Ok(metadata) => metadata, + Err(e) => { + tracing::error!("Failed to read session metadata for {}: {}", session_id, e); + let _ = stream_event( + MessageEvent::Error { + error: format!("Failed to read session metadata: {}", e), }, &task_tx, &cancel_token, @@ -230,8 +247,8 @@ async fn reply_handler( let session_config = SessionConfig { id: session::Identifier::Name(session_id.clone()), - working_dir: PathBuf::from(&session_working_dir), - schedule_id: request.scheduled_job_id.clone(), + working_dir: session_metadata.working_dir.clone(), + schedule_id: session_metadata.schedule_id.clone(), execution_mode: None, max_turns: None, retry_config: None, @@ -240,7 +257,7 @@ async fn reply_handler( let mut stream = match agent .reply( messages.clone(), - Some(session_config), + Some(session_config.clone()), Some(task_cancel.clone()), ) .await @@ -344,12 +361,13 @@ async fn reply_handler( let provider = Arc::clone(&provider); let session_path_clone = session_path.to_path_buf(); let all_messages_clone = all_messages.clone(); + let working_dir = session_config.working_dir.clone(); tokio::spawn(async move { if let Err(e) = session::persist_messages( &session_path_clone, &all_messages_clone, Some(provider), - Some(PathBuf::from(&session_working_dir)), + Some(working_dir), ) .await { @@ -358,7 +376,6 @@ async fn reply_handler( }); } } - let session_duration = session_start.elapsed(); if let Ok(metadata) = session::read_metadata(&session_path) { @@ -429,6 +446,8 @@ pub struct PermissionConfirmationRequest { #[serde(default = "default_principal_type")] principal_type: PrincipalType, action: String, + #[allow(dead_code)] + session_id: String, } fn default_principal_type() -> PrincipalType { @@ -452,11 +471,7 @@ pub async fn confirm_permission( ) -> Result, StatusCode> { verify_secret_key(&headers, &state)?; - let agent = state - .get_agent() - .await - .map_err(|_| StatusCode::PRECONDITION_FAILED)?; - + let agent = state.get_agent().await; let permission = match request.action.as_str() { "always_allow" => Permission::AlwaysAllow, "allow_once" => Permission::AllowOnce, @@ -480,6 +495,8 @@ pub async fn confirm_permission( struct ToolResultRequest { id: String, result: ToolResult>, + #[allow(dead_code)] + session_id: String, } async fn submit_tool_result( @@ -506,10 +523,7 @@ async fn submit_tool_result( } }; - let agent = state - .get_agent() - .await - .map_err(|_| StatusCode::PRECONDITION_FAILED)?; + let agent = state.get_agent().await; agent.handle_tool_result(payload.id, payload.result).await; Ok(Json(json!({"status": "ok"}))) } @@ -585,7 +599,7 @@ mod tests { }); let agent = Agent::new(); let _ = agent.update_provider(mock_provider).await; - let state = AppState::new(Arc::new(agent), "test-secret".to_string()).await; + let state = AppState::new(Arc::new(agent), "test-secret".to_string()); let app = routes(state); @@ -598,8 +612,6 @@ mod tests { serde_json::to_string(&ChatRequest { messages: vec![Message::user().with_text("test message")], session_id: Some("test-session".to_string()), - session_working_dir: "test-working-dir".to_string(), - scheduled_job_id: None, recipe_name: None, recipe_version: None, }) diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index f9afb0e41b8c..95f4ee1dbf32 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -129,7 +129,7 @@ async fn get_session_history( let messages = match session::read_messages(&session_path) { Ok(messages) => messages, Err(e) => { - tracing::error!("Failed to read session messages: {:?}", e); + error!("Failed to read session messages: {:?}", e); return Err(StatusCode::NOT_FOUND); } }; diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index e72363f4bea0..eacbfaf25149 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -2,43 +2,45 @@ use goose::agents::Agent; use goose::scheduler_trait::SchedulerTrait; use std::collections::HashMap; use std::path::PathBuf; +use std::sync::atomic::AtomicUsize; use std::sync::Arc; use tokio::sync::Mutex; +use tokio::sync::RwLock; -pub type AgentRef = Arc; +type AgentRef = Arc; #[derive(Clone)] pub struct AppState { - agent: Option, + agent: Arc>, pub secret_key: String, - pub scheduler: Arc>>>, + pub scheduler: Arc>>>, pub recipe_file_hash_map: Arc>>, + pub session_counter: Arc, } impl AppState { - pub async fn new(agent: AgentRef, secret_key: String) -> Arc { + pub fn new(agent: AgentRef, secret_key: String) -> Arc { Arc::new(Self { - agent: Some(agent.clone()), + agent: Arc::new(RwLock::new(agent)), secret_key, - scheduler: Arc::new(Mutex::new(None)), + scheduler: Arc::new(RwLock::new(None)), recipe_file_hash_map: Arc::new(Mutex::new(HashMap::new())), + session_counter: Arc::new(AtomicUsize::new(0)), }) } - pub async fn get_agent(&self) -> Result, anyhow::Error> { - self.agent - .clone() - .ok_or_else(|| anyhow::anyhow!("Agent needs to be created first.")) + pub async fn get_agent(&self) -> AgentRef { + self.agent.read().await.clone() } pub async fn set_scheduler(&self, sched: Arc) { - let mut guard = self.scheduler.lock().await; + let mut guard = self.scheduler.write().await; *guard = Some(sched); } pub async fn scheduler(&self) -> Result, anyhow::Error> { self.scheduler - .lock() + .read() .await .clone() .ok_or_else(|| anyhow::anyhow!("Scheduler not initialized")) @@ -48,4 +50,9 @@ impl AppState { let mut map = self.recipe_file_hash_map.lock().await; *map = hash_map; } + + pub async fn reset(&self) { + let mut agent = self.agent.write().await; + *agent = Arc::new(Agent::new()); + } } diff --git a/crates/goose-server/tests/pricing_api_test.rs b/crates/goose-server/tests/pricing_api_test.rs index 5065bb858ccd..d7b48b2a5910 100644 --- a/crates/goose-server/tests/pricing_api_test.rs +++ b/crates/goose-server/tests/pricing_api_test.rs @@ -8,7 +8,7 @@ use tower::ServiceExt; async fn create_test_app() -> Router { let agent = Arc::new(goose::agents::Agent::default()); - let state = goose_server::AppState::new(agent, "test".to_string()).await; + let state = goose_server::AppState::new(agent, "test".to_string()); // Add scheduler setup like in the existing tests let sched_storage_path = etcetera::choose_app_strategy(goose::config::APP_STRATEGY.clone()) diff --git a/crates/goose/src/context_mgmt/auto_compact.rs b/crates/goose/src/context_mgmt/auto_compact.rs index b5dbf46fc516..0783a8257d8d 100644 --- a/crates/goose/src/context_mgmt/auto_compact.rs +++ b/crates/goose/src/context_mgmt/auto_compact.rs @@ -270,6 +270,7 @@ mod tests { accumulated_input_tokens: Some(50), accumulated_output_tokens: Some(50), extension_data: crate::session::ExtensionData::new(), + recipe: None, } } diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index c256f684ed0b..88fe9df4cc83 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -1299,6 +1299,7 @@ async fn run_scheduled_job_internal( accumulated_input_tokens: None, accumulated_output_tokens: None, extension_data: crate::session::ExtensionData::new(), + recipe: None, }; if let Err(e_fb) = crate::session::storage::save_messages_with_metadata( &session_file_path, diff --git a/crates/goose/src/session/storage.rs b/crates/goose/src/session/storage.rs index e8365ce87da1..66f761469198 100644 --- a/crates/goose/src/session/storage.rs +++ b/crates/goose/src/session/storage.rs @@ -8,6 +8,7 @@ use crate::conversation::message::Message; use crate::conversation::Conversation; use crate::providers::base::Provider; +use crate::recipe::Recipe; use crate::session::extension_data::ExtensionData; use crate::utils::safe_truncate; use anyhow::Result; @@ -69,6 +70,8 @@ pub struct SessionMetadata { /// Extension data containing extension states #[serde(default)] pub extension_data: ExtensionData, + + pub recipe: Option, } // Custom deserializer to handle old sessions without working_dir @@ -91,6 +94,7 @@ impl<'de> Deserialize<'de> for SessionMetadata { working_dir: Option, #[serde(default)] extension_data: ExtensionData, + recipe: Option, } let helper = Helper::deserialize(deserializer)?; @@ -113,6 +117,7 @@ impl<'de> Deserialize<'de> for SessionMetadata { accumulated_output_tokens: helper.accumulated_output_tokens, working_dir, extension_data: helper.extension_data, + recipe: helper.recipe, }) } } @@ -138,6 +143,7 @@ impl SessionMetadata { accumulated_input_tokens: None, accumulated_output_tokens: None, extension_data: ExtensionData::new(), + recipe: None, } } } @@ -391,6 +397,7 @@ pub fn list_sessions() -> Result> { } /// Generate a session ID using timestamp format (yyyymmdd_hhmmss) +/// TODO(Douwe): make this actually be unique pub fn generate_session_id() -> String { Local::now().format("%Y%m%d_%H%M%S").to_string() } @@ -1150,7 +1157,7 @@ pub async fn persist_messages_with_schedule_id( pub fn save_messages_with_metadata( session_file: &Path, metadata: &SessionMetadata, - messages: &Conversation, + conversation: &Conversation, ) -> Result<()> { use fs2::FileExt; @@ -1158,10 +1165,10 @@ pub fn save_messages_with_metadata( let secure_path = get_path(Identifier::Path(session_file.to_path_buf()))?; // Security check: message count limit - if messages.len() > MAX_MESSAGE_COUNT { + if conversation.len() > MAX_MESSAGE_COUNT { tracing::warn!( "Message count exceeds limit during save: {}", - messages.len() + conversation.len() ); return Err(anyhow::anyhow!("Too many messages to save")); } @@ -1218,7 +1225,7 @@ pub fn save_messages_with_metadata( writeln!(writer)?; // Write all messages with progress tracking - for (i, message) in messages.iter().enumerate() { + for (i, message) in conversation.iter().enumerate() { serde_json::to_writer(&mut writer, &message).map_err(|e| { tracing::error!("Failed to serialize message {}: {}", i, e); anyhow::anyhow!("Failed to write session message") diff --git a/crates/goose/tests/test_support.rs b/crates/goose/tests/test_support.rs index 8fc851c35473..6b02870898e4 100644 --- a/crates/goose/tests/test_support.rs +++ b/crates/goose/tests/test_support.rs @@ -412,5 +412,6 @@ pub fn create_test_session_metadata(message_count: usize, working_dir: &str) -> accumulated_input_tokens: Some(50), accumulated_output_tokens: Some(50), extension_data: Default::default(), + recipe: None, } } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index d1eddb92aedb..32875900c01c 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -85,6 +85,45 @@ } } }, + "/agent/resume": { + "post": { + "tags": [ + "super::routes::agent" + ], + "operationId": "resume_agent", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResumeAgentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Agent started successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartAgentResponse" + } + } + } + }, + "400": { + "description": "Bad request - invalid working directory" + }, + "401": { + "description": "Unauthorized - invalid secret key" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/agent/session_config": { "post": { "tags": [ @@ -124,6 +163,45 @@ } } }, + "/agent/start": { + "post": { + "tags": [ + "super::routes::agent" + ], + "operationId": "start_agent", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartAgentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Agent started successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartAgentResponse" + } + } + } + }, + "400": { + "description": "Bad request - invalid working directory" + }, + "401": { + "description": "Unauthorized - invalid secret key" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/agent/tools": { "get": { "tags": [ @@ -140,6 +218,15 @@ "type": "string", "nullable": true } + }, + { + "name": "session_id", + "in": "query", + "description": "Required session ID to scope tools to a specific session", + "required": true, + "schema": { + "type": "string" + } } ], "responses": { @@ -209,6 +296,16 @@ "super::routes::agent" ], "operationId": "update_router_tool_selector", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRouterToolSelectorRequest" + } + } + }, + "required": true + }, "responses": { "200": { "description": "Tool selection strategy updated successfully", @@ -1414,9 +1511,13 @@ "AddSubRecipesRequest": { "type": "object", "required": [ - "sub_recipes" + "sub_recipes", + "session_id" ], "properties": { + "session_id": { + "type": "string" + }, "sub_recipes": { "type": "array", "items": { @@ -1850,11 +1951,15 @@ "ExtendPromptRequest": { "type": "object", "required": [ - "extension" + "extension", + "session_id" ], "properties": { "extension": { "type": "string" + }, + "session_id": { + "type": "string" } } }, @@ -2265,10 +2370,16 @@ }, "GetToolsQuery": { "type": "object", + "required": [ + "session_id" + ], "properties": { "extension_name": { "type": "string", "nullable": true + }, + "session_id": { + "type": "string" } } }, @@ -2644,7 +2755,8 @@ "type": "object", "required": [ "id", - "action" + "action", + "session_id" ], "properties": { "action": { @@ -2655,6 +2767,9 @@ }, "principal_type": { "$ref": "#/components/schemas/PrincipalType" + }, + "session_id": { + "type": "string" } } }, @@ -3026,6 +3141,17 @@ } } }, + "ResumeAgentRequest": { + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "session_id": { + "type": "string" + } + } + }, "RetryConfig": { "type": "object", "description": "Configuration for retry logic in recipe execution", @@ -3156,6 +3282,9 @@ }, "SessionConfigRequest": { "type": "object", + "required": [ + "session_id" + ], "properties": { "response": { "allOf": [ @@ -3164,6 +3293,9 @@ } ], "nullable": true + }, + "session_id": { + "type": "string" } } }, @@ -3342,6 +3474,14 @@ "description": "The number of output tokens used in the session. Retrieved from the provider's last usage.", "nullable": true }, + "recipe": { + "allOf": [ + { + "$ref": "#/components/schemas/Recipe" + } + ], + "nullable": true + }, "schedule_id": { "type": "string", "description": "ID of the schedule that triggered this session, if any", @@ -3388,6 +3528,47 @@ } } }, + "StartAgentRequest": { + "type": "object", + "required": [ + "working_dir" + ], + "properties": { + "recipe": { + "allOf": [ + { + "$ref": "#/components/schemas/Recipe" + } + ], + "nullable": true + }, + "working_dir": { + "type": "string" + } + } + }, + "StartAgentResponse": { + "type": "object", + "required": [ + "session_id", + "metadata", + "messages" + ], + "properties": { + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Message" + } + }, + "metadata": { + "$ref": "#/components/schemas/SessionMetadata" + }, + "session_id": { + "type": "string" + } + } + }, "SubRecipe": { "type": "object", "required": [ @@ -3645,7 +3826,8 @@ "UpdateProviderRequest": { "type": "object", "required": [ - "provider" + "provider", + "session_id" ], "properties": { "model": { @@ -3654,6 +3836,20 @@ }, "provider": { "type": "string" + }, + "session_id": { + "type": "string" + } + } + }, + "UpdateRouterToolSelectorRequest": { + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "session_id": { + "type": "string" } } }, diff --git a/ui/desktop/src/App.test.tsx b/ui/desktop/src/App.test.tsx index a4a9049f4ad5..2459ea715efd 100644 --- a/ui/desktop/src/App.test.tsx +++ b/ui/desktop/src/App.test.tsx @@ -37,12 +37,33 @@ vi.mock('./utils/costDatabase', () => ({ initializeCostDatabase: vi.fn().mockResolvedValue(undefined), })); -vi.mock('./api/sdk.gen', () => ({ - initConfig: vi.fn().mockResolvedValue(undefined), - readAllConfig: vi.fn().mockResolvedValue(undefined), - backupConfig: vi.fn().mockResolvedValue(undefined), - recoverConfig: vi.fn().mockResolvedValue(undefined), - validateConfig: vi.fn().mockResolvedValue(undefined), +vi.mock('./api/sdk.gen', () => { + const test_chat = { + data: { + session_id: 'test', + messages: [], + metadata: { + description: '', + }, + }, + }; + + return { + initConfig: vi.fn().mockResolvedValue(undefined), + readAllConfig: vi.fn().mockResolvedValue(undefined), + backupConfig: vi.fn().mockResolvedValue(undefined), + recoverConfig: vi.fn().mockResolvedValue(undefined), + validateConfig: vi.fn().mockResolvedValue(undefined), + startAgent: vi.fn().mockResolvedValue(test_chat), + resumeAgent: vi.fn().mockResolvedValue(test_chat), + }; +}); + +vi.mock('./sessions', () => ({ + fetchSessionDetails: vi + .fn() + .mockResolvedValue({ sessionId: 'test', messages: [], metadata: { description: '' } }), + generateSessionId: vi.fn(), })); vi.mock('./utils/openRouterSetup', () => ({ diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 33b00b141eea..7f7bf1a3b054 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -1,7 +1,7 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { IpcRendererEvent } from 'electron'; import { HashRouter, Routes, Route, useNavigate, useLocation } from 'react-router-dom'; -import { openSharedSessionFromDeepLink, type SessionLinksViewOptions } from './sessionLinks'; +import { openSharedSessionFromDeepLink } from './sessionLinks'; import { type SharedSessionDetails } from './sharedSessions'; import { ErrorUI } from './components/ErrorBoundary'; import { ExtensionInstallModal } from './components/ExtensionInstallModal'; @@ -13,13 +13,12 @@ import ProviderGuard from './components/ProviderGuard'; import { ChatType } from './types/chat'; import Hub from './components/hub'; -import Pair from './components/pair'; +import Pair, { PairRouteState } from './components/pair'; import SettingsView, { SettingsViewOptions } from './components/settings/SettingsView'; import SessionsView from './components/sessions/SessionsView'; import SharedSessionView from './components/sessions/SharedSessionView'; import SchedulesView from './components/schedule/SchedulesView'; import ProviderSettings from './components/settings/providers/ProviderSettingsPage'; -import { useChat } from './hooks/useChat'; import { AppLayout } from './components/Layout/AppLayout'; import { ChatProvider } from './contexts/ChatContext'; import { DraftProvider } from './contexts/DraftContext'; @@ -29,42 +28,37 @@ import { useConfig } from './components/ConfigContext'; import { ModelAndProviderProvider } from './components/ModelAndProviderContext'; import PermissionSettingsView from './components/settings/permission/PermissionSetting'; -import { type SessionDetails } from './sessions'; import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/ExtensionsView'; import { Recipe } from './recipe'; import RecipesView from './components/RecipesView'; import RecipeEditor from './components/RecipeEditor'; - -// Import the new modules import { createNavigationHandler, View, ViewOptions } from './utils/navigationUtils'; -import { initializeApp } from './utils/appInitialization'; +import { + AgentState, + InitializationContext, + NoProviderOrModelError, + useAgent, +} from './hooks/useAgent'; // Route Components const HubRouteWrapper = ({ - chat, - setChat, - setPairChat, setIsGoosehintsModalOpen, isExtensionsLoading, + resetChat, }: { - chat: ChatType; - setChat: (chat: ChatType) => void; - setPairChat: (chat: ChatType) => void; setIsGoosehintsModalOpen: (isOpen: boolean) => void; isExtensionsLoading: boolean; + resetChat: () => void; }) => { const navigate = useNavigate(); - const setView = createNavigationHandler(navigate); + const setView = useMemo(() => createNavigationHandler(navigate), [navigate]); return ( ); }; @@ -72,119 +66,40 @@ const HubRouteWrapper = ({ const PairRouteWrapper = ({ chat, setChat, - setPairChat, setIsGoosehintsModalOpen, + setAgentWaitingMessage, + setFatalError, + agentState, + loadCurrentChat, }: { chat: ChatType; setChat: (chat: ChatType) => void; - setPairChat: (chat: ChatType) => void; setIsGoosehintsModalOpen: (isOpen: boolean) => void; + setAgentWaitingMessage: (msg: string | null) => void; + setFatalError: (value: ((prevState: string | null) => string | null) | string | null) => void; + agentState: AgentState; + loadCurrentChat: (context: InitializationContext) => Promise; }) => { - const navigate = useNavigate(); const location = useLocation(); - const chatRef = useRef(chat); - const setView = createNavigationHandler(navigate); - - // Keep the ref updated with the current chat state - useEffect(() => { - chatRef.current = chat; - }, [chat]); - - // Check if we have a resumed session or recipe config from navigation state - useEffect(() => { - const appConfig = window.appConfig?.get('recipe'); - if (appConfig && !chatRef.current.recipeConfig) { - const recipe = appConfig as Recipe; - - const updatedChat: ChatType = { - ...chatRef.current, - recipeConfig: recipe, - title: recipe.title || chatRef.current.title, - messages: [], // Start fresh for recipe from deeplink - messageHistoryIndex: 0, - }; - setChat(updatedChat); - setPairChat(updatedChat); - return; - } - - // Only process navigation state if we actually have it - if (!location.state) { - console.log('No navigation state, preserving existing chat state'); - return; - } - - const resumedSession = location.state?.resumedSession as SessionDetails | undefined; - const recipeConfig = location.state?.recipeConfig as Recipe | undefined; - const resetChat = location.state?.resetChat as boolean | undefined; - - if (resumedSession) { - console.log('Loading resumed session in pair view:', resumedSession.session_id); - console.log('Current chat before resume:', chatRef.current); - - // Convert session to chat format - this clears any existing recipe config - const sessionChat: ChatType = { - id: resumedSession.session_id, - title: resumedSession.metadata?.description || `ID: ${resumedSession.session_id}`, - messages: resumedSession.messages, - messageHistoryIndex: resumedSession.messages.length, - recipeConfig: null, // Clear recipe config when resuming a session - }; - - // Update both the local chat state and the app-level pairChat state - setChat(sessionChat); - setPairChat(sessionChat); - - // Clear the navigation state to prevent reloading on navigation - window.history.replaceState({}, document.title); - } else if (recipeConfig && resetChat) { - console.log('Loading new recipe config in pair view:', recipeConfig.title); - - const updatedChat: ChatType = { - id: chatRef.current.id, // Keep the same ID - title: recipeConfig.title || 'Recipe Chat', - messages: [], // Clear messages to start fresh - messageHistoryIndex: 0, - recipeConfig: recipeConfig, - recipeParameters: null, // Clear parameters for new recipe - }; - - // Update both the local chat state and the app-level pairChat state - setChat(updatedChat); - setPairChat(updatedChat); - - // Clear the navigation state to prevent reloading on navigation - window.history.replaceState({}, document.title); - } else if (recipeConfig && !chatRef.current.recipeConfig) { - const updatedChat: ChatType = { - ...chatRef.current, - recipeConfig: recipeConfig, - title: recipeConfig.title || chatRef.current.title, - }; - - // Update both the local chat state and the app-level pairChat state - setChat(updatedChat); - setPairChat(updatedChat); - - // Clear the navigation state to prevent reloading on navigation - window.history.replaceState({}, document.title); - } else if (location.state) { - // We have navigation state but it doesn't match our conditions - // Clear it to prevent future processing, but don't modify chat state - console.log('Clearing unprocessed navigation state'); - window.history.replaceState({}, document.title); - } - // If we have a recipe config but resetChat is false and we already have a recipe, - // do nothing - just continue with the existing chat state - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [location.state]); + const navigate = useNavigate(); + const setView = useMemo(() => createNavigationHandler(navigate), [navigate]); + const routeState = + (location.state as PairRouteState) || (window.history.state as PairRouteState) || {}; + const [resumeSessionId] = useState(routeState.resumeSessionId); + const [initialMessage] = useState(routeState.initialMessage); return ( ); }; @@ -192,7 +107,7 @@ const PairRouteWrapper = ({ const SettingsRoute = () => { const location = useLocation(); const navigate = useNavigate(); - const setView = createNavigationHandler(navigate); + const setView = useMemo(() => createNavigationHandler(navigate), [navigate]); // Get viewOptions from location.state or history.state const viewOptions = @@ -202,7 +117,7 @@ const SettingsRoute = () => { const SessionsRoute = () => { const navigate = useNavigate(); - const setView = createNavigationHandler(navigate); + const setView = useMemo(() => createNavigationHandler(navigate), [navigate]); return ; }; @@ -213,22 +128,7 @@ const SchedulesRoute = () => { }; const RecipesRoute = () => { - const navigate = useNavigate(); - - return ( - { - // Navigate to pair view with the recipe configuration in state - navigate('/pair', { - state: { - recipeConfig: recipe, - // Reset the pair chat to start fresh with the recipe - resetChat: true, - }, - }); - }} - /> - ); + return ; }; const RecipeEditorRoute = () => { @@ -311,12 +211,20 @@ const ConfigureProvidersRoute = () => { ); }; -const WelcomeRoute = () => { +interface WelcomeRouteProps { + onSelectProvider: () => void; +} + +const WelcomeRoute = ({ onSelectProvider }: WelcomeRouteProps) => { const navigate = useNavigate(); + const onClose = useCallback(() => { + onSelectProvider(); + navigate('/'); + }, [navigate, onSelectProvider]); return (
- navigate('/')} isOnboarding={true} /> +
); }; @@ -398,107 +306,58 @@ const ExtensionsRoute = () => { export default function App() { const [fatalError, setFatalError] = useState(null); - const [isLoadingSession, setIsLoadingSession] = useState(false); const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false); const [agentWaitingMessage, setAgentWaitingMessage] = useState(null); const [isLoadingSharedSession, setIsLoadingSharedSession] = useState(false); const [sharedSessionError, setSharedSessionError] = useState(null); const [isExtensionsLoading, setIsExtensionsLoading] = useState(false); - // Add separate state for pair chat to maintain its own conversation - const [pairChat, setPairChat] = useState({ - id: generateSessionId(), + const [didSyncUrlParams, setDidSyncUrlParams] = useState(false); + + const [viewType, setViewType] = useState(null); + const [resumeSessionId, setResumeSessionId] = useState(null); + + const [didSelectProvider, setDidSelectProvider] = useState(false); + + const [recipeFromAppConfig, setRecipeFromAppConfig] = useState( + (window.appConfig?.get('recipe') as Recipe) || null + ); + + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + + const viewType = urlParams.get('view') || null; + const resumeSessionId = urlParams.get('resumeSessionId') || null; + + setViewType(viewType); + setResumeSessionId(resumeSessionId); + setDidSyncUrlParams(true); + }, []); + + const [chat, _setChat] = useState({ + sessionId: generateSessionId(), title: 'Pair Chat', messages: [], messageHistoryIndex: 0, recipeConfig: null, }); - const { getExtensions, addExtension, read } = useConfig(); - const initAttemptedRef = useRef(false); - - // Create a setView function for useChat hook - we'll use window.history instead of navigate - const setView = (view: View, viewOptions: ViewOptions = {}) => { - console.log(`Setting view to: ${view}`, viewOptions); - console.trace('setView called from:'); // This will show the call stack - // Convert view to route navigation using hash routing - switch (view) { - case 'chat': - window.location.hash = '#/'; - break; - case 'pair': - window.location.hash = '#/pair'; - break; - case 'settings': - window.location.hash = '#/settings'; - break; - case 'extensions': - window.location.hash = '#/extensions'; - break; - case 'sessions': - window.location.hash = '#/sessions'; - break; - case 'schedules': - window.location.hash = '#/schedules'; - break; - case 'recipes': - window.location.hash = '#/recipes'; - break; - case 'permission': - window.location.hash = '#/permission'; - break; - case 'ConfigureProviders': - window.location.hash = '#/configure-providers'; - break; - case 'sharedSession': - window.location.hash = '#/shared-session'; - break; - case 'recipeEditor': - window.location.hash = '#/recipe-editor'; - break; - case 'welcome': - window.location.hash = '#/welcome'; - break; - default: - console.error(`Unknown view: ${view}, not navigating anywhere. This is likely a bug.`); - console.trace('Invalid setView call stack:'); - // Don't navigate anywhere for unknown views to avoid unexpected redirects - break; - } - }; - - const { chat, setChat } = useChat({ setIsLoadingSession, setView, setPairChat }); + const setChat = useCallback( + (update) => { + _setChat(update); + }, + [_setChat] + ); - useEffect(() => { - if (initAttemptedRef.current) { - console.log('Initialization already attempted, skipping...'); - return; + const { addExtension } = useConfig(); + const { agentState, loadCurrentChat, resetChat } = useAgent(); + const resetChatIfNecessary = useCallback(() => { + if (chat.messages.length > 0) { + setResumeSessionId(null); + setRecipeFromAppConfig(null); + resetChat(); } - initAttemptedRef.current = true; - - const initialize = async () => { - const config = window.electron.getConfig(); - const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER; - const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL; - - await initializeApp({ - getExtensions, - addExtension, - setPairChat, - setMessage: setAgentWaitingMessage, - setIsExtensionsLoading, - provider: provider as string, - model: model as string, - }); - }; - - initialize() - .then(() => setAgentWaitingMessage(null)) - .catch((error) => { - console.error('Fatal error during initialization:', error); - setFatalError(error instanceof Error ? error.message : 'Unknown error occurred'); - }); - }, [getExtensions, addExtension, read, setPairChat, setAgentWaitingMessage]); + }, [resetChat, chat.messages.length]); useEffect(() => { console.log('Sending reactReady signal to Electron'); @@ -512,24 +371,78 @@ export default function App() { } }, []); - // Handle navigation to pair view for recipe deeplinks after router is ready + // Handle URL parameters and deeplinks on app startup useEffect(() => { - const recipeConfig = window.appConfig?.get('recipe'); - if ( - recipeConfig && - typeof recipeConfig === 'object' && - window.location.hash === '#/' && - !window.sessionStorage.getItem('ignoreRecipeConfigChanges') - ) { - console.log('Router ready - navigating to pair view for recipe deeplink:', recipeConfig); - // Small delay to ensure router is fully initialized - setTimeout(() => { - window.location.hash = '#/pair'; - }, 100); - } else if (window.sessionStorage.getItem('ignoreRecipeConfigChanges')) { - console.log('Router ready - ignoring recipe config navigation due to new window creation'); + if (!didSyncUrlParams) { + return; } - }, []); + + const stateData: PairRouteState = { + resumeSessionId: resumeSessionId || undefined, + }; + (async () => { + try { + await loadCurrentChat({ + setAgentWaitingMessage, + setIsExtensionsLoading, + recipeConfig: recipeFromAppConfig || undefined, + ...stateData, + }); + } catch (e) { + if (e instanceof NoProviderOrModelError) { + // the onboarding flow will trigger + } else { + throw e; + } + } + })(); + + if (resumeSessionId || recipeFromAppConfig) { + window.location.hash = '#/pair'; + window.history.replaceState(stateData, '', '#/pair'); + return; + } + + if (!viewType) { + if (window.location.hash === '' || window.location.hash === '#') { + window.location.hash = '#/'; + window.history.replaceState({}, '', '#/'); + } + } else { + if (viewType === 'recipeEditor' && recipeFromAppConfig) { + window.location.hash = '#/recipe-editor'; + window.history.replaceState({ config: recipeFromAppConfig }, '', '#/recipe-editor'); + } else { + const routeMap: Record = { + chat: '#/', + pair: '#/pair', + settings: '#/settings', + sessions: '#/sessions', + schedules: '#/schedules', + recipes: '#/recipes', + permission: '#/permission', + ConfigureProviders: '#/configure-providers', + sharedSession: '#/shared-session', + recipeEditor: '#/recipe-editor', + welcome: '#/welcome', + }; + + const route = routeMap[viewType]; + if (route) { + window.location.hash = route; + window.history.replaceState({}, '', route); + } + } + } + }, [ + recipeFromAppConfig, + resetChat, + loadCurrentChat, + setAgentWaitingMessage, + didSyncUrlParams, + resumeSessionId, + viewType, + ]); useEffect(() => { const handleOpenSharedSession = async (_event: IpcRendererEvent, ...args: unknown[]) => { @@ -538,16 +451,13 @@ export default function App() { setIsLoadingSharedSession(true); setSharedSessionError(null); try { - await openSharedSessionFromDeepLink( - link, - (_view: View, _options?: SessionLinksViewOptions) => { - // Navigate to shared session view with the session data - window.location.hash = '#/shared-session'; - if (_options) { - window.history.replaceState(_options, '', '#/shared-session'); - } + await openSharedSessionFromDeepLink(link, (_view: View, _options?: ViewOptions) => { + // Navigate to shared session view with the session data + window.location.hash = '#/shared-session'; + if (_options) { + window.history.replaceState(_options, '', '#/shared-session'); } - ); + }); } catch (error) { console.error('Unexpected error opening shared session:', error); // Navigate to shared session view with error @@ -569,63 +479,6 @@ export default function App() { }; }, []); - // Handle recipe decode events from main process - useEffect(() => { - const handleLoadRecipeDeeplink = (_event: IpcRendererEvent, ...args: unknown[]) => { - const recipeDeeplink = args[0] as string; - const scheduledJobId = args[1] as string | undefined; - - // Store the deeplink info in app config for processing - const config = window.electron.getConfig(); - config.recipeDeeplink = recipeDeeplink; - if (scheduledJobId) { - config.scheduledJobId = scheduledJobId; - } - - // Navigate to pair view to handle the recipe loading - if (window.location.hash !== '#/pair') { - window.location.hash = '#/pair'; - } - }; - - const handleRecipeDecoded = (_event: IpcRendererEvent, ...args: unknown[]) => { - const decodedRecipe = args[0] as Recipe; - - // Update the pair chat with the decoded recipe - setPairChat((prevChat) => ({ - ...prevChat, - recipeConfig: decodedRecipe, - title: decodedRecipe.title || 'Recipe Chat', - messages: [], // Start fresh for recipe - messageHistoryIndex: 0, - })); - - // Navigate to pair view if not already there - if (window.location.hash !== '#/pair') { - window.location.hash = '#/pair'; - } - }; - - const handleRecipeDecodeError = (_event: IpcRendererEvent, ...args: unknown[]) => { - const errorMessage = args[0] as string; - console.error('[App] Recipe decode error:', errorMessage); - - // Show error to user - you could add a toast notification here - // For now, just log the error and navigate to recipes page - window.location.hash = '#/recipes'; - }; - - window.electron.on('load-recipe-deeplink', handleLoadRecipeDeeplink); - window.electron.on('recipe-decoded', handleRecipeDecoded); - window.electron.on('recipe-decode-error', handleRecipeDecodeError); - - return () => { - window.electron.off('load-recipe-deeplink', handleLoadRecipeDeeplink); - window.electron.off('recipe-decoded', handleRecipeDecoded); - window.electron.off('recipe-decode-error', handleRecipeDecodeError); - }; - }, [setPairChat, pairChat.id]); - useEffect(() => { console.log('Setting up keyboard shortcuts'); const handleKeyDown = (event: KeyboardEvent) => { @@ -696,14 +549,13 @@ export default function App() { const handleFatalError = (_event: IpcRendererEvent, ...args: unknown[]) => { const errorMessage = args[0] as string; console.error('Encountered a fatal error:', errorMessage); - console.error('Is loading session:', isLoadingSession); setFatalError(errorMessage); }; window.electron.on('fatal-error', handleFatalError); return () => { window.electron.off('fatal-error', handleFatalError); }; - }, [isLoadingSession]); + }, []); useEffect(() => { const handleSetView = (_event: IpcRendererEvent, ...args: unknown[]) => { @@ -758,14 +610,6 @@ export default function App() { return ; } - if (isLoadingSession) { - return ( -
-
-
- ); - } - return ( @@ -789,124 +633,67 @@ export default function App() {
- } /> + setDidSelectProvider(true)} />} + /> } /> - - + + + + + } > - - + } /> - - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - + } /> + } /> + } /> + } /> + } /> + } /> + } /> - - - } - /> - - - + } /> + } />
diff --git a/ui/desktop/src/agent/index.ts b/ui/desktop/src/agent/index.ts deleted file mode 100644 index 79ba9e7696fc..000000000000 --- a/ui/desktop/src/agent/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { updateAgentProvider } from '../api'; - -interface initializeAgentProps { - model: string; - provider: string; -} - -export async function initializeAgent({ model, provider }: initializeAgentProps) { - const response = await updateAgentProvider({ - body: { - provider: provider.toLowerCase().replace(/ /g, '_'), - model: model, - }, - }); - - if (response.error) { - throw new Error(`Failed to initialize agent: ${response.error}`); - } - - return response; -} diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 208017617c7f..1e6425e11929 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options as ClientOptions, TDataShape, Client } from './client'; -import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ScanRecipeData, ScanRecipeResponses, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionHistoryData, GetSessionHistoryResponses, GetSessionHistoryErrors } from './types.gen'; +import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, ResumeAgentData, ResumeAgentResponses, ResumeAgentErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, StartAgentData, StartAgentResponses, StartAgentErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ScanRecipeData, ScanRecipeResponses, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionHistoryData, GetSessionHistoryResponses, GetSessionHistoryErrors } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -40,6 +40,17 @@ export const extendPrompt = (options: Opti }); }; +export const resumeAgent = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/agent/resume', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + export const updateSessionConfig = (options: Options) => { return (options.client ?? _heyApiClient).post({ url: '/agent/session_config', @@ -51,8 +62,19 @@ export const updateSessionConfig = (option }); }; -export const getTools = (options?: Options) => { - return (options?.client ?? _heyApiClient).get({ +export const startAgent = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/agent/start', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + +export const getTools = (options: Options) => { + return (options.client ?? _heyApiClient).get({ url: '/agent/tools', ...options }); @@ -69,10 +91,14 @@ export const updateAgentProvider = (option }); }; -export const updateRouterToolSelector = (options?: Options) => { - return (options?.client ?? _heyApiClient).post({ +export const updateRouterToolSelector = (options: Options) => { + return (options.client ?? _heyApiClient).post({ url: '/agent/update_router_tool_selector', - ...options + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } }); }; diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 4edbb65c77b0..b68f663d0bd5 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -1,6 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts export type AddSubRecipesRequest = { + session_id: string; sub_recipes: Array; }; @@ -164,6 +165,7 @@ export type ErrorResponse = { export type ExtendPromptRequest = { extension: string; + session_id: string; }; export type ExtendPromptResponse = { @@ -316,6 +318,7 @@ export type FrontendToolRequest = { export type GetToolsQuery = { extension_name?: string | null; + session_id: string; }; export type ImageContent = { @@ -413,6 +416,7 @@ export type PermissionConfirmationRequest = { action: string; id: string; principal_type?: PrincipalType; + session_id: string; }; /** @@ -589,6 +593,10 @@ export type Response = { json_schema?: unknown; }; +export type ResumeAgentRequest = { + session_id: string; +}; + /** * Configuration for retry logic in recipe execution */ @@ -643,6 +651,7 @@ export type ScheduledJob = { export type SessionConfigRequest = { response?: Response | null; + session_id: string; }; export type SessionDisplayInfo = { @@ -719,6 +728,7 @@ export type SessionMetadata = { * The number of output tokens used in the session. Retrieved from the provider's last usage. */ output_tokens?: number | null; + recipe?: Recipe | null; /** * ID of the schedule that triggered this session, if any */ @@ -743,6 +753,17 @@ export type Settings = { temperature?: number | null; }; +export type StartAgentRequest = { + recipe?: Recipe | null; + working_dir: string; +}; + +export type StartAgentResponse = { + messages: Array; + metadata: SessionMetadata; + session_id: string; +}; + export type SubRecipe = { description?: string | null; name: string; @@ -841,6 +862,11 @@ export type ToolResponse = { export type UpdateProviderRequest = { model?: string | null; provider: string; + session_id: string; +}; + +export type UpdateRouterToolSelectorRequest = { + session_id: string; }; export type UpdateScheduleRequest = { @@ -911,6 +937,37 @@ export type ExtendPromptResponses = { export type ExtendPromptResponse2 = ExtendPromptResponses[keyof ExtendPromptResponses]; +export type ResumeAgentData = { + body: ResumeAgentRequest; + path?: never; + query?: never; + url: '/agent/resume'; +}; + +export type ResumeAgentErrors = { + /** + * Bad request - invalid working directory + */ + 400: unknown; + /** + * Unauthorized - invalid secret key + */ + 401: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type ResumeAgentResponses = { + /** + * Agent started successfully + */ + 200: StartAgentResponse; +}; + +export type ResumeAgentResponse = ResumeAgentResponses[keyof ResumeAgentResponses]; + export type UpdateSessionConfigData = { body: SessionConfigRequest; path?: never; @@ -942,14 +999,49 @@ export type UpdateSessionConfigResponses = { export type UpdateSessionConfigResponse = UpdateSessionConfigResponses[keyof UpdateSessionConfigResponses]; +export type StartAgentData = { + body: StartAgentRequest; + path?: never; + query?: never; + url: '/agent/start'; +}; + +export type StartAgentErrors = { + /** + * Bad request - invalid working directory + */ + 400: unknown; + /** + * Unauthorized - invalid secret key + */ + 401: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type StartAgentResponses = { + /** + * Agent started successfully + */ + 200: StartAgentResponse; +}; + +export type StartAgentResponse2 = StartAgentResponses[keyof StartAgentResponses]; + export type GetToolsData = { body?: never; path?: never; - query?: { + query: { /** * Optional extension name to filter tools */ extension_name?: string | null; + /** + * Required session ID to scope tools to a specific session + */ + session_id: string; }; url: '/agent/tools'; }; @@ -1012,7 +1104,7 @@ export type UpdateAgentProviderResponses = { }; export type UpdateRouterToolSelectorData = { - body?: never; + body: UpdateRouterToolSelectorRequest; path?: never; query?: never; url: '/agent/update_router_tool_selector'; diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 096320a3f0de..7ceea05d1e3d 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -41,7 +41,7 @@ * while remaining flexible enough to support different UI contexts (Hub vs Pair). */ -import React, { useEffect, useContext, createContext, useRef } from 'react'; +import React, { createContext, useContext, useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; import { SearchView } from './conversation/SearchView'; import { AgentHeader } from './AgentHeader'; @@ -63,31 +63,31 @@ import { useFileDrop } from '../hooks/useFileDrop'; import { useCostTracking } from '../hooks/useCostTracking'; import { Message } from '../types/message'; import { ChatState } from '../types/chatState'; +import { ChatType } from '../types/chat'; +import { useToolCount } from './alerts/useToolCount'; // Context for sharing current model info const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null); export const useCurrentModelInfo = () => useContext(CurrentModelContext); -import { ChatType } from '../types/chat'; - interface BaseChatProps { chat: ChatType; setChat: (chat: ChatType) => void; setView: (view: View, viewOptions?: ViewOptions) => void; setIsGoosehintsModalOpen?: (isOpen: boolean) => void; - enableLocalStorage?: boolean; onMessageStreamFinish?: () => void; - onMessageSubmit?: (message: string) => void; // Callback after message is submitted + onMessageSubmit?: (message: string) => void; renderHeader?: () => React.ReactNode; renderBeforeMessages?: () => React.ReactNode; renderAfterMessages?: () => React.ReactNode; customChatInputProps?: Record; customMainLayoutProps?: Record; - contentClassName?: string; // Add custom class for content area - disableSearch?: boolean; // Disable search functionality (for Hub) - showPopularTopics?: boolean; // Show popular chat topics in empty state (for Pair) - suppressEmptyState?: boolean; // Suppress empty state content (for transitions) + contentClassName?: string; + disableSearch?: boolean; + showPopularTopics?: boolean; + suppressEmptyState?: boolean; autoSubmit?: boolean; + loadingChat: boolean; } function BaseChatContent({ @@ -95,7 +95,6 @@ function BaseChatContent({ setChat, setView, setIsGoosehintsModalOpen, - enableLocalStorage = false, onMessageStreamFinish, onMessageSubmit, renderHeader, @@ -108,6 +107,7 @@ function BaseChatContent({ showPopularTopics = false, suppressEmptyState = false, autoSubmit = false, + loadingChat = false, }: BaseChatProps) { const location = useLocation(); const scrollRef = useRef(null); @@ -131,7 +131,6 @@ function BaseChatContent({ error, setMessages, input, - setInput: _setInput, handleSubmit: engineHandleSubmit, onStopGoose, sessionTokenCount, @@ -158,7 +157,6 @@ function BaseChatContent({ setHasStartedUsingRecipe(true); } }, - enableLocalStorage, }); // Use shared recipe manager @@ -177,7 +175,7 @@ function BaseChatContent({ handleRecipeAccept, handleRecipeCancel, hasSecurityWarnings, - } = useRecipeManager(messages, location.state); + } = useRecipeManager(chat, location.state?.recipeConfig); // Reset recipe usage tracking when recipe changes useEffect(() => { @@ -251,6 +249,8 @@ function BaseChatContent({ engineHandleSubmit(combinedTextFromInput); }; + const toolCount = useToolCount(chat.sessionId); + // Wrapper for append that tracks recipe usage const appendWithTracking = (text: string | Message) => { // Mark that user has started using the recipe when they use append @@ -324,13 +324,11 @@ function BaseChatContent({ {/* Messages or RecipeActivities or Popular Topics */} { // Check if we should show splash instead of messages - (() => { - // Show splash if we have a recipe and user hasn't started using it yet, and recipe has been accepted - const shouldShowSplash = - recipeConfig && recipeAccepted && !hasStartedUsingRecipe && !suppressEmptyState; - - return shouldShowSplash; - })() ? ( + // Show splash if we have a recipe and user hasn't started using it yet, and recipe has been accepted + loadingChat ? null : recipeConfig && + recipeAccepted && + !hasStartedUsingRecipe && + !suppressEmptyState ? ( <> {/* Show RecipeActivities when we have a recipe config and user hasn't started using it */} {recipeConfig ? ( @@ -416,7 +414,7 @@ function BaseChatContent({ null as Message | null ); if (lastUserMessage) { - append(lastUserMessage); + await append(lastUserMessage); } }} > @@ -426,6 +424,7 @@ function BaseChatContent({
)} +
) : showPopularTopics ? ( @@ -439,10 +438,16 @@ function BaseChatContent({ {/* Fixed loading indicator at bottom left of chat container */} - {(chatState !== ChatState.Idle || isCompacting) && ( + {(chatState !== ChatState.Idle || loadingChat || isCompacting) && (
@@ -453,6 +458,7 @@ function BaseChatContent({ className={`relative z-10 ${disableAnimation ? '' : 'animate-[fadein_400ms_ease-in_forwards]'}`} > void; chatState: ChatState; onStop?: () => void; @@ -83,6 +83,7 @@ interface ChatInputProps { recipeConfig?: Recipe | null; recipeAccepted?: boolean; initialPrompt?: string; + toolCount: number; autoSubmit: boolean; setAncestorMessages?: (messages: Message[]) => void; append?: (message: Message) => void; @@ -90,6 +91,7 @@ interface ChatInputProps { } export default function ChatInput({ + sessionId, handleSubmit, chatState = ChatState.Idle, onStop, @@ -109,6 +111,7 @@ export default function ChatInput({ recipeConfig, recipeAccepted, initialPrompt, + toolCount, autoSubmit = false, append, setAncestorMessages, @@ -133,7 +136,6 @@ export default function ChatInput({ const dropdownRef: React.RefObject = useRef( null ) as React.RefObject; - const toolCount = useToolCount(); const { isCompacting, handleManualCompaction } = useContextManager(); const { getProviders, read } = useConfig(); const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider(); @@ -296,33 +298,16 @@ export default function ChatInput({ setHasUserTyped(false); }, [initialValue]); // Keep only initialValue as a dependency - // Track if we've already set the recipe prompt to avoid re-setting it - const hasSetRecipePromptRef = useRef(false); - // Handle recipe prompt updates useEffect(() => { // If recipe is accepted and we have an initial prompt, and no messages yet, and we haven't set it before - if ( - recipeAccepted && - initialPrompt && - messages.length === 0 && - !hasSetRecipePromptRef.current - ) { + if (recipeAccepted && initialPrompt && messages.length === 0) { setDisplayValue(initialPrompt); setValue(initialPrompt); - hasSetRecipePromptRef.current = true; setTimeout(() => { textAreaRef.current?.focus(); }, 0); } - // we don't need hasSetRecipePromptRef in the dependency array because it is a ref that persists across renders - }, [recipeAccepted, initialPrompt, messages.length]); - - // Reset the recipe prompt flag when the recipe changes or messages are added - useEffect(() => { - if (messages.length > 0 || !recipeAccepted || !initialPrompt) { - hasSetRecipePromptRef.current = false; - } }, [recipeAccepted, initialPrompt, messages.length]); // Draft functionality - load draft if no initial value or recipe @@ -920,6 +905,14 @@ export default function ChatInput({ return true; // Return true if message was queued }; + const canSubmit = + !isLoading && + !isCompacting && + agentIsReady && + (displayValue.trim() || + pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) || + allDroppedFiles.some((file) => !file.error && !file.isLoading)); + const performSubmit = useCallback( (text?: string) => { const validPastedImageFilesPaths = pastedImages @@ -1061,13 +1054,6 @@ export default function ChatInput({ return; } - const canSubmit = - !isLoading && - !isCompacting && - agentIsReady && - (displayValue.trim() || - pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) || - allDroppedFiles.some((file) => !file.error && !file.isLoading)); if (canSubmit) { performSubmit(); } @@ -1575,6 +1561,7 @@ export default function ChatInput({
Promise; + changeModel: (sessionId: string | null, model: Model) => Promise; getCurrentModelAndProvider: () => Promise<{ model: string; provider: string }>; getFallbackModelAndProvider: () => Promise<{ model: string; provider: string }>; getCurrentModelAndProviderForDisplay: () => Promise<{ model: string; provider: string }>; @@ -44,50 +43,43 @@ const ModelAndProviderContext = createContext = ({ children }) => { const [currentModel, setCurrentModel] = useState(null); const [currentProvider, setCurrentProvider] = useState(null); - const { read, upsert, getProviders, config } = useConfig(); + const { read, upsert, getProviders } = useConfig(); const changeModel = useCallback( - async (model: Model) => { + async (sessionId: string | null, model: Model) => { const modelName = model.name; const providerName = model.provider; - try { - await initializeAgent({ - model: model.name, - provider: model.provider, - }); - } catch (error) { - console.error(`Failed to change model at agent step -- ${modelName} ${providerName}`); - toastError({ - title: CHANGE_MODEL_ERROR_TITLE, - msg: SWITCH_MODEL_AGENT_ERROR_MSG, - traceback: error instanceof Error ? error.message : String(error), - }); - // don't write to config - return; - } + let phase = 'agent'; try { + if (sessionId) { + await updateAgentProvider({ + body: { + session_id: sessionId, + provider: providerName, + model: modelName, + }, + }); + } + + phase = 'config'; await upsert('GOOSE_PROVIDER', providerName, false); await upsert('GOOSE_MODEL', modelName, false); - // Update local state setCurrentProvider(providerName); setCurrentModel(modelName); + + toastSuccess({ + title: CHANGE_MODEL_TOAST_TITLE, + msg: `${SWITCH_MODEL_SUCCESS_MSG} -- using ${model.alias ?? modelName} from ${model.subtext ?? providerName}`, + }); } catch (error) { - console.error(`Failed to change model at config step -- ${modelName} ${providerName}}`); + console.error(`Failed to change model at ${phase} step -- ${modelName} ${providerName}`); toastError({ title: CHANGE_MODEL_ERROR_TITLE, - msg: CONFIG_UPDATE_ERROR_MSG, + msg: phase === 'agent' ? SWITCH_MODEL_AGENT_ERROR_MSG : CONFIG_UPDATE_ERROR_MSG, traceback: error instanceof Error ? error.message : String(error), }); - // agent and config will be out of sync at this point - // TODO: reset agent to use current config settings - } finally { - // show toast - toastSuccess({ - title: CHANGE_MODEL_TOAST_TITLE, - msg: `${SWITCH_MODEL_SUCCESS_MSG} -- using ${model.alias ?? modelName} from ${model.subtext ?? providerName}`, - }); } }, [upsert] @@ -183,19 +175,6 @@ export const ModelAndProviderProvider: React.FC = refreshCurrentModelAndProvider(); }, [refreshCurrentModelAndProvider]); - // Extract config values for dependency array - const configObj = config as Record; - const gooseModel = configObj?.GOOSE_MODEL; - const gooseProvider = configObj?.GOOSE_PROVIDER; - - // Listen for config changes and refresh when GOOSE_MODEL or GOOSE_PROVIDER changes - useEffect(() => { - // Only refresh if the config has loaded and model/provider values exist - if (config && Object.keys(config).length > 0 && (gooseModel || gooseProvider)) { - refreshCurrentModelAndProvider(); - } - }, [config, gooseModel, gooseProvider, refreshCurrentModelAndProvider]); - const contextValue = useMemo( () => ({ currentModel, diff --git a/ui/desktop/src/components/OllamaSetup.test.tsx b/ui/desktop/src/components/OllamaSetup.test.tsx index 945a8beb0831..5c4674a748ce 100644 --- a/ui/desktop/src/components/OllamaSetup.test.tsx +++ b/ui/desktop/src/components/OllamaSetup.test.tsx @@ -143,7 +143,8 @@ describe('OllamaSetup', () => { }); }); - describe('when Ollama and model are both available', () => { + // TODO: re-enable when we have ollama back in the onboarding + describe.skip('when Ollama and model are both available', () => { beforeEach(() => { vi.mocked(ollamaDetection.checkOllamaStatus).mockResolvedValue({ isRunning: true, diff --git a/ui/desktop/src/components/OllamaSetup.tsx b/ui/desktop/src/components/OllamaSetup.tsx index 9f54f0b1353e..40063ebd8edc 100644 --- a/ui/desktop/src/components/OllamaSetup.tsx +++ b/ui/desktop/src/components/OllamaSetup.tsx @@ -9,18 +9,18 @@ import { getPreferredModel, type PullProgress, } from '../utils/ollamaDetection'; -import { initializeSystem } from '../utils/providerUtils'; +//import { initializeSystem } from '../utils/providerUtils'; import { toastService } from '../toasts'; import { Ollama } from './icons'; interface OllamaSetupProps { onSuccess: () => void; onCancel: () => void; - setIsExtensionsLoading?: (loading: boolean) => void; } -export function OllamaSetup({ onSuccess, onCancel, setIsExtensionsLoading }: OllamaSetupProps) { - const { addExtension, getExtensions, upsert } = useConfig(); +export function OllamaSetup({ onSuccess, onCancel }: OllamaSetupProps) { + //const { addExtension, getExtensions, upsert } = useConfig(); + const { upsert } = useConfig(); const [isChecking, setIsChecking] = useState(true); const [ollamaDetected, setOllamaDetected] = useState(false); const [isPolling, setIsPolling] = useState(false); @@ -110,13 +110,6 @@ export function OllamaSetup({ onSuccess, onCancel, setIsExtensionsLoading }: Oll await upsert('GOOSE_MODEL', getPreferredModel(), false); await upsert('OLLAMA_HOST', 'localhost', false); - // Initialize the system with Ollama - await initializeSystem('ollama', getPreferredModel(), { - getExtensions, - addExtension, - setIsExtensionsLoading, - }); - toastService.success({ title: 'Success!', msg: `Connected to Ollama with ${getPreferredModel()} model.`, diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index 1e88121a758a..2d6ed51a275b 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -14,7 +14,7 @@ * - Configurable batch size and delay */ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Message } from '../types/message'; import GooseMessage from './GooseMessage'; import UserMessage from './UserMessage'; @@ -22,10 +22,11 @@ import { CompactionMarker } from './context_management/CompactionMarker'; import { useContextManager } from './context_management/ContextManager'; import { NotificationEvent } from '../hooks/useMessageStream'; import LoadingGoose from './LoadingGoose'; +import { ChatType } from '../types/chat'; interface ProgressiveMessageListProps { messages: Message[]; - chat?: { id: string; messageHistoryIndex: number }; // Make optional for session history + chat?: Pick; toolCallNotifications?: Map; // Make optional append?: (value: string) => void; // Make optional appendMessage?: (message: Message) => void; // Make optional @@ -152,8 +153,7 @@ export default function ProgressiveMessageList({ // Render messages up to the current rendered count const renderMessages = useCallback(() => { const messagesToRender = messages.slice(0, renderedCount); - - const renderedMessages = messagesToRender + return messagesToRender .map((message, index) => { // Use custom render function if provided if (renderMessage) { @@ -170,7 +170,7 @@ export default function ProgressiveMessageList({ const isUser = isUserMessage(message); - const result = ( + return (
) : ( ); - - return result; }) - .filter(Boolean); // Filter out null values - - return renderedMessages; + .filter(Boolean); }, [ messages, renderedCount, diff --git a/ui/desktop/src/components/ProviderGuard.tsx b/ui/desktop/src/components/ProviderGuard.tsx index fc821c15eff6..1eae8e2cd02f 100644 --- a/ui/desktop/src/components/ProviderGuard.tsx +++ b/ui/desktop/src/components/ProviderGuard.tsx @@ -5,7 +5,6 @@ import { SetupModal } from './SetupModal'; import { startOpenRouterSetup } from '../utils/openRouterSetup'; import { startTetrateSetup } from '../utils/tetrateSetup'; import WelcomeGooseLogo from './WelcomeGooseLogo'; -import { initializeSystem } from '../utils/providerUtils'; import { toastService } from '../toasts'; import { OllamaSetup } from './OllamaSetup'; @@ -13,12 +12,12 @@ import { Goose } from './icons/Goose'; import { OpenRouter } from './icons'; interface ProviderGuardProps { + didSelectProvider: boolean; children: React.ReactNode; - setIsExtensionsLoading?: (loading: boolean) => void; } -export default function ProviderGuard({ children, setIsExtensionsLoading }: ProviderGuardProps) { - const { read, getExtensions, addExtension } = useConfig(); +export default function ProviderGuard({ didSelectProvider, children }: ProviderGuardProps) { + const { read } = useConfig(); const navigate = useNavigate(); const [isChecking, setIsChecking] = useState(true); const [hasProvider, setHasProvider] = useState(false); @@ -69,13 +68,6 @@ export default function ProviderGuard({ children, setIsExtensionsLoading }: Prov const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL; if (provider && model) { - // Initialize the system with the new provider/model - await initializeSystem(provider as string, model as string, { - getExtensions, - addExtension, - setIsExtensionsLoading, - }); - toastService.configure({ silent: false }); toastService.success({ title: 'Success!', @@ -136,13 +128,6 @@ export default function ProviderGuard({ children, setIsExtensionsLoading }: Prov const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL; if (provider && model) { - // Initialize the system with the new provider/model - await initializeSystem(provider as string, model as string, { - getExtensions, - addExtension, - setIsExtensionsLoading, - }); - toastService.configure({ silent: false }); toastService.success({ title: 'Success!', @@ -207,8 +192,11 @@ export default function ProviderGuard({ children, setIsExtensionsLoading }: Prov }; checkProvider(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [read]); + }, [ + navigate, + read, + didSelectProvider, // When the user makes a selection, re-trigger this check + ]); if ( isChecking && @@ -270,7 +258,6 @@ export default function ProviderGuard({ children, setIsExtensionsLoading }: Prov setShowOllamaSetup(false); setShowFirstTimeSetup(true); }} - setIsExtensionsLoading={setIsExtensionsLoading} />
diff --git a/ui/desktop/src/components/RecipesView.tsx b/ui/desktop/src/components/RecipesView.tsx index 553914711b4a..abf60be7b06e 100644 --- a/ui/desktop/src/components/RecipesView.tsx +++ b/ui/desktop/src/components/RecipesView.tsx @@ -16,12 +16,7 @@ import { toastSuccess, toastError } from '../toasts'; import { useEscapeKey } from '../hooks/useEscapeKey'; import { deleteRecipe, RecipeManifestResponse } from '../api'; -interface RecipesViewProps { - onLoadRecipe?: (recipe: Recipe) => void; -} - -// @ts-expect-error until we make onLoadRecipe work for loading recipes in the same window -export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { +export default function RecipesView() { const [savedRecipes, setSavedRecipes] = useState([]); const [loading, setLoading] = useState(true); const [showSkeleton, setShowSkeleton] = useState(true); diff --git a/ui/desktop/src/components/ToolCallConfirmation.tsx b/ui/desktop/src/components/ToolCallConfirmation.tsx index 764a460402a9..a5e8becfb619 100644 --- a/ui/desktop/src/components/ToolCallConfirmation.tsx +++ b/ui/desktop/src/components/ToolCallConfirmation.tsx @@ -21,6 +21,7 @@ const toolConfirmationState = new Map< >(); interface ToolConfirmationProps { + sessionId: string; isCancelledMessage: boolean; isClicked: boolean; toolConfirmationId: string; @@ -28,6 +29,7 @@ interface ToolConfirmationProps { } export default function ToolConfirmation({ + sessionId, isCancelledMessage, isClicked, toolConfirmationId, @@ -68,34 +70,37 @@ export default function ToolConfirmation({ } }, [isClicked, clicked, status, toolName, toolConfirmationId]); - const handleButtonClick = async (action: string) => { - const newClicked = true; - const newStatus = action; - let newActionDisplay = ''; + const handleButtonClick = async (newStatus: string) => { + let newActionDisplay; - if (action === ALWAYS_ALLOW) { + if (newStatus === ALWAYS_ALLOW) { newActionDisplay = 'always allowed'; - } else if (action === ALLOW_ONCE) { + } else if (newStatus === ALLOW_ONCE) { newActionDisplay = 'allowed once'; } else { newActionDisplay = 'denied'; } // Update local state - setClicked(newClicked); + setClicked(true); setStatus(newStatus); setActionDisplay(newActionDisplay); // Store in global state for persistence across navigation toolConfirmationState.set(toolConfirmationId, { - clicked: newClicked, + clicked: true, status: newStatus, actionDisplay: newActionDisplay, }); try { const response = await confirmPermission({ - body: { id: toolConfirmationId, action, principal_type: 'Tool' }, + body: { + session_id: sessionId, + id: toolConfirmationId, + action: newStatus, + principal_type: 'Tool', + }, }); if (response.error) { console.error('Failed to confirm permission:', response.error); diff --git a/ui/desktop/src/components/alerts/useToolCount.ts b/ui/desktop/src/components/alerts/useToolCount.ts index 94e00f60acfc..b1a931f1bf82 100644 --- a/ui/desktop/src/components/alerts/useToolCount.ts +++ b/ui/desktop/src/components/alerts/useToolCount.ts @@ -3,7 +3,7 @@ import { getTools } from '../../api'; const { clearTimeout } = window; -export const useToolCount = () => { +export const useToolCount = (sessionId: string) => { const [toolCount, setToolCount] = useState(null); useEffect(() => { @@ -11,7 +11,7 @@ export const useToolCount = () => { const fetchTools = async () => { try { - const response = await getTools(); + const response = await getTools({ query: { session_id: sessionId } }); if (!response.error && response.data) { setToolCount(response.data.length); } else { @@ -30,7 +30,7 @@ export const useToolCount = () => { return () => { clearTimeout(timeoutId); }; - }, []); + }, [sessionId]); return toolCount; }; diff --git a/ui/desktop/src/components/context_management/index.ts b/ui/desktop/src/components/context_management/index.ts index 1f656adbf4c3..195469ba64ee 100644 --- a/ui/desktop/src/components/context_management/index.ts +++ b/ui/desktop/src/components/context_management/index.ts @@ -50,6 +50,7 @@ export async function manageContextFromBackend({ } // Function to convert API Message to frontend Message +// TODO(Douwe): get rid of this and use the API Message format everywhere export function convertApiMessageToFrontendMessage( apiMessage: ApiMessage, display?: boolean, diff --git a/ui/desktop/src/components/hub.tsx b/ui/desktop/src/components/hub.tsx index 116ddf8b3b94..de9876b7b8e8 100644 --- a/ui/desktop/src/components/hub.tsx +++ b/ui/desktop/src/components/hub.tsx @@ -7,44 +7,30 @@ * Key Responsibilities: * - Displays SessionInsights to show session statistics and recent chats * - Provides a ChatInput for users to start new conversations - * - Creates a new chat session with the submitted message and navigates to Pair + * - Navigates to Pair with the submitted message to start a new conversation * - Ensures each submission from Hub always starts a fresh conversation * * Navigation Flow: * Hub (input submission) → Pair (new conversation with the submitted message) - * - * Unlike the previous implementation that used BaseChat, the Hub now uses only - * ChatInput directly, which allows for clean separation between the landing page - * and active conversation states. This ensures that every message submitted from - * the Hub creates a brand new chat session in the Pair view. */ import { SessionInsights } from './sessions/SessionsInsights'; import ChatInput from './ChatInput'; -import { generateSessionId } from '../sessions'; import { ChatState } from '../types/chatState'; import { ContextManagerProvider } from './context_management/ContextManager'; import 'react-toastify/dist/ReactToastify.css'; - -import { ChatType } from '../types/chat'; -import { DEFAULT_CHAT_TITLE } from '../contexts/ChatContext'; import { View, ViewOptions } from '../utils/navigationUtils'; export default function Hub({ - chat: _chat, - setChat: _setChat, - setPairChat, setView, setIsGoosehintsModalOpen, isExtensionsLoading, + resetChat, }: { - readyForAutoUserPrompt: boolean; - chat: ChatType; - setChat: (chat: ChatType) => void; - setPairChat: (chat: ChatType) => void; setView: (view: View, viewOptions?: ViewOptions) => void; setIsGoosehintsModalOpen: (isOpen: boolean) => void; isExtensionsLoading: boolean; + resetChat: () => void; }) { // Handle chat input submission - create new chat and navigate to pair const handleSubmit = (e: React.FormEvent) => { @@ -52,29 +38,15 @@ export default function Hub({ const combinedTextFromInput = customEvent.detail?.value || ''; if (combinedTextFromInput.trim()) { - // Always create a completely new chat session with a unique ID for the PAIR - const newChatId = generateSessionId(); - const newPairChat = { - id: newChatId, // This generates a unique ID each time - title: DEFAULT_CHAT_TITLE, - messages: [], // Always start with empty messages - messageHistoryIndex: 0, - recipeConfig: null, // Clear recipe for new chats from Hub - recipeParameters: null, // Clear parameters for new chats from Hub - }; - - // Update the PAIR chat state immediately to prevent flashing - setPairChat(newPairChat); - - // Navigate to pair page with the message to be submitted immediately + // Navigate to pair page with the message to be submitted + // Pair will handle creating the new chat session + resetChat(); setView('pair', { disableAnimation: true, initialMessage: combinedTextFromInput, - resetChat: true, }); } - // Prevent default form submission e.preventDefault(); }; @@ -86,6 +58,7 @@ export default function Hub({
diff --git a/ui/desktop/src/components/pair.tsx b/ui/desktop/src/components/pair.tsx index 13512ae3ddab..6a60fec1a732 100644 --- a/ui/desktop/src/components/pair.tsx +++ b/ui/desktop/src/components/pair.tsx @@ -1,145 +1,111 @@ -/** - * Pair Component - * - * The Pair component represents the active conversation mode in the Goose Desktop application. - * This is where users engage in ongoing conversations with the AI assistant after transitioning - * from the Hub's initial welcome screen. - * - * Key Responsibilities: - * - Manages active chat sessions with full message history - * - Handles transitions from Hub with initial input processing - * - Provides the main conversational interface for extended interactions - * - Enables local storage persistence for conversation continuity - * - Supports all advanced chat features like file attachments, tool usage, etc. - * - * Navigation Flow: - * Hub (initial message) → Pair (active conversation) → Hub (new session) - * - * The Pair component is essentially a specialized wrapper around BaseChat that: - * - Processes initial input from the Hub transition - * - Enables conversation persistence - * - Provides the full-featured chat experience - * - * Unlike Hub, Pair assumes an active conversation state and focuses on - * maintaining conversation flow rather than onboarding new users. - */ - import { useEffect, useState } from 'react'; -import { useLocation } from 'react-router-dom'; import { View, ViewOptions } from '../utils/navigationUtils'; import BaseChat from './BaseChat'; import { useRecipeManager } from '../hooks/useRecipeManager'; import { useIsMobile } from '../hooks/use-mobile'; import { useSidebar } from './ui/sidebar'; +import { AgentState, InitializationContext } from '../hooks/useAgent'; import 'react-toastify/dist/ReactToastify.css'; import { cn } from '../utils'; import { ChatType } from '../types/chat'; -import { DEFAULT_CHAT_TITLE } from '../contexts/ChatContext'; + +export interface PairRouteState { + resumeSessionId?: string; + initialMessage?: string; +} + +interface PairProps { + chat: ChatType; + setChat: (chat: ChatType) => void; + setView: (view: View, viewOptions?: ViewOptions) => void; + setIsGoosehintsModalOpen: (isOpen: boolean) => void; + setFatalError: (value: ((prevState: string | null) => string | null) | string | null) => void; + setAgentWaitingMessage: (msg: string | null) => void; + agentState: AgentState; + loadCurrentChat: (context: InitializationContext) => Promise; +} export default function Pair({ chat, setChat, setView, setIsGoosehintsModalOpen, -}: { - chat: ChatType; - setChat: (chat: ChatType) => void; - setView: (view: View, viewOptions?: ViewOptions) => void; - setIsGoosehintsModalOpen: (isOpen: boolean) => void; -}) { - const location = useLocation(); + setFatalError, + setAgentWaitingMessage, + agentState, + loadCurrentChat, + resumeSessionId, + initialMessage, +}: PairProps & PairRouteState) { const isMobile = useIsMobile(); const { state: sidebarState } = useSidebar(); const [hasProcessedInitialInput, setHasProcessedInitialInput] = useState(false); const [shouldAutoSubmit, setShouldAutoSubmit] = useState(false); - const [initialMessage, setInitialMessage] = useState(null); + const [messageToSubmit, setMessageToSubmit] = useState(null); const [isTransitioningFromHub, setIsTransitioningFromHub] = useState(false); + const [loadingChat, setLoadingChat] = useState(false); - // Get recipe configuration and parameter handling - const { initialPrompt: recipeInitialPrompt } = useRecipeManager(chat.messages, location.state); - - // Handle recipe loading from recipes view - reset chat if needed useEffect(() => { - if (location.state?.resetChat && location.state?.recipeConfig) { - // Reset the chat to start fresh with the recipe - const newChat = { - id: chat.id, // Keep the same ID to maintain the session - title: location.state.recipeConfig.title || 'Recipe Chat', - messages: [], // Clear messages to start fresh - messageHistoryIndex: 0, - recipeConfig: location.state.recipeConfig, // Set the recipe config in chat state - recipeParameters: null, // Clear parameters for new recipe - }; - setChat(newChat); - - // Clear the location state to prevent re-processing - window.history.replaceState({}, '', '/pair'); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [location.state, chat.id]); - - // Handle initial message from hub page + const initializeFromState = async () => { + setLoadingChat(true); + try { + const chat = await loadCurrentChat({ + resumeSessionId: resumeSessionId, + setAgentWaitingMessage, + }); + setChat(chat); + } catch (error) { + console.log(error); + setFatalError(`Agent init failure: ${error instanceof Error ? error.message : '' + error}`); + } finally { + setLoadingChat(false); + } + }; + initializeFromState(); + }, [ + agentState, + setChat, + setFatalError, + setAgentWaitingMessage, + loadCurrentChat, + resumeSessionId, + ]); + + // Followed by sending the initialMessage if we have one. This will happen + // only once, unless we reset the chat in step one. useEffect(() => { - const messageFromHub = location.state?.initialMessage; - const resetChat = location.state?.resetChat; - - // If we have a resetChat flag from Hub, clear any existing recipe config - // This scenario occurs when a user navigates from Hub to start a new chat, - // ensuring any previous recipe configuration is cleared for a fresh start - if (resetChat) { - const newChat: ChatType = { - ...chat, - recipeConfig: null, - recipeParameters: null, - title: DEFAULT_CHAT_TITLE, - messages: [], // Clear messages for fresh start - messageHistoryIndex: 0, - }; - setChat(newChat); + if (agentState !== AgentState.INITIALIZED || !initialMessage || hasProcessedInitialInput) { + return; } - // Reset processing state when we have a new message from hub - if (messageFromHub) { - // Set transitioning state to prevent showing popular topics - setIsTransitioningFromHub(true); + setIsTransitioningFromHub(true); + setHasProcessedInitialInput(true); + setMessageToSubmit(initialMessage); + setShouldAutoSubmit(true); + }, [agentState, initialMessage, hasProcessedInitialInput]); - // If this is a different message than what we processed before, reset the flag - if (messageFromHub !== initialMessage) { - setHasProcessedInitialInput(false); - } - - if (!hasProcessedInitialInput) { - setHasProcessedInitialInput(true); - setInitialMessage(messageFromHub); - setShouldAutoSubmit(true); - - // Clear the location state to prevent re-processing - window.history.replaceState({}, '', '/pair'); - } + useEffect(() => { + if (agentState === AgentState.NO_PROVIDER) { + setView('welcome'); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [location.state, hasProcessedInitialInput, initialMessage]); + }, [agentState, setView]); + + const { initialPrompt: recipeInitialPrompt } = useRecipeManager(chat, chat.recipeConfig || null); - // Custom message submit handler const handleMessageSubmit = (message: string) => { - // This is called after a message is submitted + // Clean up any auto submit state: setShouldAutoSubmit(false); - setIsTransitioningFromHub(false); // Clear transitioning state once message is submitted + setIsTransitioningFromHub(false); + setMessageToSubmit(null); console.log('Message submitted:', message); }; - // Custom message stream finish handler to handle recipe auto-execution - const handleMessageStreamFinish = () => { - // This will be called with the proper append function from BaseChat - // For now, we'll handle auto-execution in the BaseChat component - }; + const recipePrompt = + agentState === 'initialized' && chat.messages.length === 0 && recipeInitialPrompt; - // Determine the initial value for the chat input - // Priority: Hub message > Recipe prompt > empty - const initialValue = initialMessage || recipeInitialPrompt || undefined; + const initialValue = messageToSubmit || recipePrompt || undefined; - // Custom chat input props for Pair-specific behavior const customChatInputProps = { // Pass initial message from Hub or recipe prompt initialValue, @@ -148,13 +114,12 @@ export default function Pair({ return ( = ({ scheduleId, onN setSelectedSessionDetails(null); setSessionDetailsError(null); }} - onRetry={() => loadAndShowSessionDetails(selectedSessionDetails.session_id)} + onRetry={() => loadAndShowSessionDetails(selectedSessionDetails?.sessionId)} showActionButtons={true} /> ); diff --git a/ui/desktop/src/components/sessions/SessionHistoryView.tsx b/ui/desktop/src/components/sessions/SessionHistoryView.tsx index 713f97d0d81d..82cd8fe25d7e 100644 --- a/ui/desktop/src/components/sessions/SessionHistoryView.tsx +++ b/ui/desktop/src/components/sessions/SessionHistoryView.tsx @@ -11,7 +11,7 @@ import { LoaderCircle, AlertCircle, } from 'lucide-react'; -import { type SessionDetails } from '../../sessions'; +import { resumeSession, type SessionDetails } from '../../sessions'; import { Button } from '../ui/button'; import { toast } from 'react-toastify'; import { MainPanelLayout } from '../Layout/MainPanelLayout'; @@ -38,10 +38,7 @@ const isUserMessage = (message: Message): boolean => { if (message.role === 'assistant') { return false; } - if (message.content.every((c) => c.type === 'toolConfirmationRequest')) { - return false; - } - return true; + return !message.content.every((c) => c.type === 'toolConfirmationRequest'); }; const filterMessagesForDisplay = (messages: Message[]): Message[] => { @@ -112,7 +109,7 @@ const SessionMessages: React.FC<{ = ({ session.metadata.working_dir, session.messages, session.metadata.description || 'Shared Session', - session.metadata.total_tokens + session.metadata.total_tokens || 0 ); const shareableLink = `goose://sessions/${shareToken}`; @@ -219,31 +216,10 @@ const SessionHistoryView: React.FC = ({ }; const handleLaunchInNewWindow = () => { - if (session) { - console.log('Launching session in new window:', session.session_id); - console.log('Session details:', session); - - // Get the working directory from the session metadata - const workingDir = session.metadata?.working_dir; - - if (workingDir) { - console.log( - `Opening new window with session ID: ${session.session_id}, in working dir: ${workingDir}` - ); - - // Create a new chat window with the working directory and session ID - window.electron.createChatWindow( - undefined, // query - workingDir, // dir - undefined, // version - session.session_id // resumeSessionId - ); - - console.log('createChatWindow called successfully'); - } else { - console.error('No working directory found in session metadata'); - toast.error('Could not launch session: Missing working directory'); - } + try { + resumeSession(session); + } catch (error) { + toast.error(`Could not launch session: ${error instanceof Error ? error.message : error}`); } }; @@ -312,7 +288,7 @@ const SessionHistoryView: React.FC = ({ {session.metadata.total_tokens !== null && ( - {session.metadata.total_tokens.toLocaleString()} + {(session.metadata.total_tokens || 0).toLocaleString()} )} diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index 01af1b88642c..a108903d9549 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -413,7 +413,7 @@ const SessionListView: React.FC = React.memo(
- {session.metadata.total_tokens.toLocaleString()} + {(session.metadata.total_tokens || 0).toLocaleString()}
)} diff --git a/ui/desktop/src/components/sessions/SessionsInsights.tsx b/ui/desktop/src/components/sessions/SessionsInsights.tsx index 2f161b67e410..8001a1967794 100644 --- a/ui/desktop/src/components/sessions/SessionsInsights.tsx +++ b/ui/desktop/src/components/sessions/SessionsInsights.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { Card, CardContent, CardDescription } from '../ui/card'; import { getApiUrl } from '../../config'; import { Greeting } from '../common/Greeting'; -import { fetchSessions, fetchSessionDetails, type Session } from '../../sessions'; +import { fetchSessions, type Session, resumeSession } from '../../sessions'; import { useNavigate } from 'react-router-dom'; import { Button } from '../ui/button'; import { ChatSmart } from '../icons/'; @@ -104,21 +104,13 @@ export function SessionInsights() { }; }, []); - const handleSessionClick = async (sessionId: string) => { + const handleSessionClick = async (session: Session) => { try { - // Fetch the session details - const sessionDetails = await fetchSessionDetails(sessionId); - - // Navigate to pair view with the resumed session - navigate('/pair', { - state: { resumedSession: sessionDetails }, - replace: true, - }); + resumeSession(session); } catch (error) { - console.error('Failed to load session:', error); - // Fallback to the sessions view if loading fails + console.error('Failed to start session:', error); navigate('/sessions', { - state: { selectedSessionId: sessionId }, + state: { selectedSessionId: session.id }, replace: true, }); } @@ -358,13 +350,13 @@ export function SessionInsights() {
handleSessionClick(session.id)} + onClick={() => handleSessionClick(session)} role="button" tabIndex={0} style={{ animationDelay: `${index * 0.1}s` }} onKeyDown={async (e) => { if (e.key === 'Enter' || e.key === ' ') { - await handleSessionClick(session.id); + await handleSessionClick(session); } }} > diff --git a/ui/desktop/src/components/sessions/SessionsView.tsx b/ui/desktop/src/components/sessions/SessionsView.tsx index fe1414e654d8..7b56bf13111e 100644 --- a/ui/desktop/src/components/sessions/SessionsView.tsx +++ b/ui/desktop/src/components/sessions/SessionsView.tsx @@ -68,7 +68,7 @@ const SessionsView: React.FC = ({ setView }) => { const handleRetryLoadSession = () => { if (selectedSession) { - loadSessionDetails(selectedSession.session_id); + loadSessionDetails(selectedSession.sessionId); } }; @@ -78,7 +78,7 @@ const SessionsView: React.FC = ({ setView }) => { = ({ setView }) => { ); }; diff --git a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx index 74226e697496..6da275d3ee4f 100644 --- a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx +++ b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx @@ -1,7 +1,7 @@ import { Sliders, ChefHat, Bot, Eye, Save } from 'lucide-react'; import React, { useEffect, useState } from 'react'; import { useModelAndProvider } from '../../../ModelAndProviderContext'; -import { AddModelModal } from '../subcomponents/AddModelModal'; +import { SwitchModelModal } from '../subcomponents/SwitchModelModal'; import { LeadWorkerSettings } from '../subcomponents/LeadWorkerSettings'; import { View } from '../../../../utils/navigationUtils'; import { @@ -22,6 +22,7 @@ import { toastSuccess, toastError } from '../../../../toasts'; import ViewRecipeModal from '../../../ViewRecipeModal'; interface ModelsBottomBarProps { + sessionId: string | null; dropdownRef: React.RefObject; setView: (view: View) => void; alerts: Alert[]; @@ -30,6 +31,7 @@ interface ModelsBottomBarProps { } export default function ModelsBottomBar({ + sessionId, dropdownRef, setView, alerts, @@ -284,7 +286,11 @@ export default function ModelsBottomBar({ {isAddModelModalOpen ? ( - setIsAddModelModalOpen(false)} /> + setIsAddModelModalOpen(false)} + /> ) : null} {isLeadWorkerModalOpen ? ( diff --git a/ui/desktop/src/components/settings/models/subcomponents/ModelSettingsButtons.tsx b/ui/desktop/src/components/settings/models/subcomponents/ModelSettingsButtons.tsx index 83b49e97c05e..bc20bbfe2e38 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/ModelSettingsButtons.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/ModelSettingsButtons.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { Button } from '../../../ui/button'; -import { AddModelModal } from './AddModelModal'; +import { SwitchModelModal } from './SwitchModelModal'; import type { View } from '../../../../utils/navigationUtils'; import { shouldShowPredefinedModels } from '../predefinedModelsUtils'; @@ -23,7 +23,11 @@ export default function ModelSettingsButtons({ setView }: ConfigureModelButtonsP Switch models {isAddModelModalOpen ? ( - setIsAddModelModalOpen(false)} /> + setIsAddModelModalOpen(false)} + /> ) : null} {!hasPredefinedModels && (