diff --git a/ui/goose2/src-tauri/src/commands/acp.rs b/ui/goose2/src-tauri/src/commands/acp.rs index e665d306bc..bd54a210d5 100644 --- a/ui/goose2/src-tauri/src/commands/acp.rs +++ b/ui/goose2/src-tauri/src/commands/acp.rs @@ -149,6 +149,16 @@ pub async fn acp_prepare_session( AcpService::prepare_session(app_handle, session_id, provider_id, working_dir, persona_id).await } +#[tauri::command] +pub async fn acp_set_model( + app_handle: AppHandle, + session_id: String, + model_id: String, +) -> Result<(), String> { + let manager = GooseAcpManager::start(app_handle).await?; + manager.set_model(session_id, model_id).await +} + /// List all sessions known to the goose binary. #[tauri::command] pub async fn acp_list_sessions(app_handle: AppHandle) -> Result, String> { diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index 741f2dbee8..4d87eb3aa5 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -46,6 +46,7 @@ pub fn run() { commands::agents::get_avatars_dir, commands::acp::discover_acp_providers, commands::acp::acp_prepare_session, + commands::acp::acp_set_model, commands::acp::acp_send_message, commands::acp::acp_cancel_session, commands::acp::acp_list_sessions, diff --git a/ui/goose2/src-tauri/src/services/acp/manager.rs b/ui/goose2/src-tauri/src/services/acp/manager.rs index f900fa77af..2da75e7d97 100644 --- a/ui/goose2/src-tauri/src/services/acp/manager.rs +++ b/ui/goose2/src-tauri/src/services/acp/manager.rs @@ -1,26 +1,17 @@ mod command_dispatch; mod dispatcher; mod session_ops; +mod thread; -use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use acp_client::MessageWriter; -use agent_client_protocol::{ - Agent, ClientSideConnection, ExtRequest, Implementation, InitializeRequest, ProtocolVersion, -}; -use futures::{SinkExt, StreamExt}; +use agent_client_protocol::{Agent, ClientSideConnection, ExtRequest}; use serde::Deserialize; use serde_json::value::RawValue; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::sync::{mpsc, oneshot, Mutex, OnceCell}; -use tokio_tungstenite::connect_async; -use tokio_tungstenite::tungstenite::Message; -use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; +use tokio::sync::{mpsc, oneshot, OnceCell}; -use super::goose_serve::{GooseServeProcess, WS_BRIDGE_BUFFER_BYTES}; -use dispatcher::{SessionEventDispatcher, SessionRoute}; pub use session_ops::AcpSessionInfo; enum ManagerCommand { @@ -71,6 +62,11 @@ enum ManagerCommand { session_id: String, response: oneshot::Sender>, }, + SetModel { + local_session_id: String, + model_id: String, + response: oneshot::Sender>, + }, } pub struct GooseAcpManager { @@ -227,6 +223,24 @@ impl GooseAcpManager { .map_err(|_| "Goose ACP manager dropped cancel request".to_string())? } + pub async fn set_model( + &self, + local_session_id: String, + model_id: String, + ) -> Result<(), String> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ManagerCommand::SetModel { + local_session_id, + model_id, + response: response_tx, + }) + .map_err(|_| "Goose ACP manager is unavailable".to_string())?; + response_rx + .await + .map_err(|_| "Goose ACP manager dropped set model request".to_string())? + } + /// Export a session as JSON via the goose binary. pub async fn export_session(&self, session_id: String) -> Result { let (response_tx, response_rx) = oneshot::channel(); @@ -290,148 +304,5 @@ fn run_manager_thread( command_rx: mpsc::UnboundedReceiver, ready_tx: oneshot::Sender>, ) { - let runtime = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(runtime) => runtime, - Err(error) => { - let _ = ready_tx.send(Err(format!( - "Failed to build Goose ACP manager runtime: {error}" - ))); - return; - } - }; - - let local = tokio::task::LocalSet::new(); - local.block_on(&runtime, async move { - let setup_result = async { - GooseServeProcess::start().await?; - let process = GooseServeProcess::get()?; - let ws_stream = connect_async(process.ws_url()) - .await - .map(|(stream, _)| stream) - .map_err(|error| format!("Failed to connect to goose serve: {error}"))?; - - let (mut ws_writer, mut ws_reader) = ws_stream.split(); - let (connection_outgoing, bridge_outgoing_reader) = - tokio::io::duplex(WS_BRIDGE_BUFFER_BYTES); - let (bridge_incoming_writer, connection_incoming) = - tokio::io::duplex(WS_BRIDGE_BUFFER_BYTES); - - tokio::task::spawn_local(async move { - let mut reader = BufReader::new(bridge_outgoing_reader); - let mut line = String::new(); - - loop { - line.clear(); - let bytes_read = match reader.read_line(&mut line).await { - Ok(bytes_read) => bytes_read, - Err(error) => { - log::error!("Failed to read ACP output for Goose WebSocket: {error}"); - break; - } - }; - if bytes_read == 0 { - break; - } - - let payload = line.trim_end_matches(['\r', '\n']); - if payload.is_empty() { - continue; - } - - if let Err(error) = ws_writer.send(Message::Text(payload.to_string())).await { - log::error!("Failed to write Goose WebSocket frame: {error}"); - break; - } - } - - let _ = ws_writer.close().await; - }); - - tokio::task::spawn_local(async move { - let mut writer = bridge_incoming_writer; - - while let Some(message) = ws_reader.next().await { - match message { - Ok(Message::Text(text)) => { - if let Err(error) = writer.write_all(text.as_bytes()).await { - log::error!( - "Failed to forward Goose WebSocket payload to ACP client: {error}" - ); - break; - } - if let Err(error) = writer.write_all(b"\n").await { - log::error!( - "Failed to delimit Goose WebSocket payload for ACP client: {error}" - ); - break; - } - if let Err(error) = writer.flush().await { - log::error!("Failed to flush Goose WebSocket payload: {error}"); - break; - } - } - Ok(Message::Close(_)) => break, - Ok(Message::Binary(_)) - | Ok(Message::Ping(_)) - | Ok(Message::Pong(_)) - | Ok(Message::Frame(_)) => {} - Err(error) => { - log::error!("Goose WebSocket read failed: {error}"); - break; - } - } - } - - let _ = writer.shutdown().await; - }); - - let routes = Arc::new(Mutex::new(HashMap::::new())); - let dispatcher = Arc::new(SessionEventDispatcher::new( - app_handle.clone(), - Arc::clone(&routes), - )); - let (connection, io_future) = ClientSideConnection::new( - dispatcher.clone(), - connection_outgoing.compat_write(), - connection_incoming.compat(), - |future| { - tokio::task::spawn_local(future); - }, - ); - let connection = Arc::new(connection); - - tokio::task::spawn_local(async move { - if let Err(error) = io_future.await { - log::error!("Goose ACP IO error: {error:?}"); - } - }); - - connection - .initialize( - InitializeRequest::new(ProtocolVersion::LATEST) - .client_info(Implementation::new("goose2", env!("CARGO_PKG_VERSION"))), - ) - .await - .map_err(|error| format!("Goose ACP initialize failed: {error:?}"))?; - - Ok::<_, String>((connection, dispatcher)) - } - .await; - - let (connection, dispatcher) = match setup_result { - Ok(parts) => { - let _ = ready_tx.send(Ok(())); - parts - } - Err(error) => { - let _ = ready_tx.send(Err(error)); - return; - } - }; - - command_dispatch::dispatch_commands(command_rx, connection, dispatcher).await; - }); + thread::run_manager_thread(app_handle, command_rx, ready_tx); } diff --git a/ui/goose2/src-tauri/src/services/acp/manager/command_dispatch.rs b/ui/goose2/src-tauri/src/services/acp/manager/command_dispatch.rs index afcdd81e09..6ce59a6c52 100644 --- a/ui/goose2/src-tauri/src/services/acp/manager/command_dispatch.rs +++ b/ui/goose2/src-tauri/src/services/acp/manager/command_dispatch.rs @@ -8,7 +8,7 @@ use tokio::sync::{mpsc, Mutex}; use super::dispatcher::SessionEventDispatcher; use super::session_ops::{ cancel_session_inner, list_sessions_inner, load_session_inner, prepare_session_inner, - send_prompt_inner, AcpSessionInfo, ManagerState, PrepareSessionInput, + send_prompt_inner, set_model_inner, AcpSessionInfo, ManagerState, PrepareSessionInput, }; use super::{call_ext_method, GooseProvidersResponse, ManagerCommand}; @@ -233,6 +233,26 @@ pub(super) async fn dispatch_commands( let _ = response.send(result); }); } + ManagerCommand::SetModel { + local_session_id, + model_id, + response, + } => { + let connection = Arc::clone(&connection); + let dispatcher = dispatcher.clone(); + let state = Arc::clone(&state); + tokio::task::spawn_local(async move { + let result = set_model_inner( + &connection, + &dispatcher, + &state, + &local_session_id, + &model_id, + ) + .await; + let _ = response.send(result); + }); + } } } } diff --git a/ui/goose2/src-tauri/src/services/acp/manager/dispatcher.rs b/ui/goose2/src-tauri/src/services/acp/manager/dispatcher.rs index 816e2f317f..60912e693f 100644 --- a/ui/goose2/src-tauri/src/services/acp/manager/dispatcher.rs +++ b/ui/goose2/src-tauri/src/services/acp/manager/dispatcher.rs @@ -14,16 +14,37 @@ use tauri::Emitter; use tokio::sync::Mutex; use crate::services::acp::payloads::{ - DonePayload, MessageCreatedPayload, ModelStatePayload, SessionInfoPayload, TextPayload, - ToolCallPayload, ToolResultPayload, ToolTitlePayload, + model_options_from_state, DonePayload, MessageCreatedPayload, ModelOption, ModelStatePayload, + SessionInfoPayload, TextPayload, ToolCallPayload, ToolResultPayload, ToolTitlePayload, }; +fn model_options_from_select_options(options: &SessionConfigSelectOptions) -> Vec { + match options { + SessionConfigSelectOptions::Ungrouped(values) => values + .iter() + .map(|value| ModelOption { + id: value.value.to_string(), + name: value.name.clone(), + }) + .collect(), + SessionConfigSelectOptions::Grouped(groups) => groups + .iter() + .flat_map(|group| group.options.iter()) + .map(|value| ModelOption { + id: value.value.to_string(), + name: value.name.clone(), + }) + .collect(), + _ => Vec::new(), + } +} + #[derive(Clone)] pub(super) struct SessionRoute { pub(super) local_session_id: String, + pub(super) provider_id: Option, pub(super) writer: Option>, pub(super) canceled: bool, - /// Tracks the current assistant message ID during replay (no writer). pub(super) replay_message_id: Option, } @@ -40,13 +61,24 @@ impl SessionEventDispatcher { Self { app_handle, routes } } - pub(super) async fn bind_session(&self, goose_session_id: &str, local_session_id: &str) { + pub(super) async fn bind_session( + &self, + goose_session_id: &str, + local_session_id: &str, + provider_id: Option<&str>, + ) { let mut routes = self.routes.lock().await; routes .entry(goose_session_id.to_string()) - .and_modify(|route| route.local_session_id = local_session_id.to_string()) + .and_modify(|route| { + route.local_session_id = local_session_id.to_string(); + if let Some(pid) = provider_id { + route.provider_id = Some(pid.to_string()); + } + }) .or_insert_with(|| SessionRoute { local_session_id: local_session_id.to_string(), + provider_id: provider_id.map(ToString::to_string), writer: None, canceled: false, replay_message_id: None, @@ -57,6 +89,7 @@ impl SessionEventDispatcher { &self, goose_session_id: &str, local_session_id: &str, + provider_id: Option<&str>, writer: Arc, ) { let mut routes = self.routes.lock().await; @@ -64,6 +97,7 @@ impl SessionEventDispatcher { goose_session_id.to_string(), SessionRoute { local_session_id: local_session_id.to_string(), + provider_id: provider_id.map(ToString::to_string), writer: Some(writer), canceled: false, replay_message_id: None, @@ -125,18 +159,26 @@ impl SessionEventDispatcher { ); } - pub(super) fn emit_model_state(&self, local_session_id: &str, state: &SessionModelState) { + pub(super) fn emit_model_state( + &self, + local_session_id: &str, + provider_id: Option<&str>, + state: &SessionModelState, + ) { let current_model_name = state .available_models .iter() .find(|model| model.model_id == state.current_model_id) .map(|model| model.name.clone()); + let available_models = model_options_from_state(state); let _ = self.app_handle.emit( "acp:model_state", ModelStatePayload { session_id: local_session_id.to_string(), + provider_id: provider_id.map(ToString::to_string), current_model_id: state.current_model_id.to_string(), current_model_name, + available_models, }, ); } @@ -144,6 +186,7 @@ impl SessionEventDispatcher { pub(super) fn emit_model_state_from_options( &self, local_session_id: &str, + provider_id: Option<&str>, options: &[SessionConfigOption], ) { let Some(option) = options @@ -157,6 +200,7 @@ impl SessionEventDispatcher { return; }; let current_model_id = select.current_value.to_string(); + let available_models = model_options_from_select_options(&select.options); let current_model_name = match &select.options { SessionConfigSelectOptions::Ungrouped(values) => values .iter() @@ -174,8 +218,10 @@ impl SessionEventDispatcher { "acp:model_state", ModelStatePayload { session_id: local_session_id.to_string(), + provider_id: provider_id.map(ToString::to_string), current_model_id, current_model_name, + available_models, }, ); } @@ -252,7 +298,11 @@ impl Client for SessionEventDispatcher { return Ok(()); } SessionUpdate::ConfigOptionUpdate(update) => { - self.emit_model_state_from_options(&route.local_session_id, &update.config_options); + self.emit_model_state_from_options( + &route.local_session_id, + route.provider_id.as_deref(), + &update.config_options, + ); return Ok(()); } _ => {} diff --git a/ui/goose2/src-tauri/src/services/acp/manager/session_ops.rs b/ui/goose2/src-tauri/src/services/acp/manager/session_ops.rs index 057321b9ea..117d350ac6 100644 --- a/ui/goose2/src-tauri/src/services/acp/manager/session_ops.rs +++ b/ui/goose2/src-tauri/src/services/acp/manager/session_ops.rs @@ -1,16 +1,21 @@ +mod prompt_ops; +#[cfg(test)] +mod tests; + use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; -use acp_client::MessageWriter; use agent_client_protocol::{ - Agent, CancelNotification, ClientSideConnection, ContentBlock as AcpContentBlock, ImageContent, - ListSessionsRequest, LoadSessionRequest, NewSessionRequest, PromptRequest, SessionConfigKind, - SessionConfigOption, SessionConfigSelectOptions, SetSessionConfigOptionRequest, TextContent, + Agent, CancelNotification, ClientSideConnection, ListSessionsRequest, LoadSessionRequest, + NewSessionRequest, SessionConfigKind, SessionConfigOption, SessionConfigSelectOptions, + SetSessionConfigOptionRequest, }; use tokio::sync::Mutex; use super::dispatcher::SessionEventDispatcher; +use crate::services::acp::split_composite_key; +pub(super) use prompt_ops::send_prompt_inner; /// Lightweight session metadata returned by `list_sessions`. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] @@ -124,7 +129,11 @@ pub(super) async fn prepare_session_inner( if let Some(prepared) = existing_prepared { dispatcher - .bind_session(&prepared.goose_session_id, &local_session_id) + .bind_session( + &prepared.goose_session_id, + &local_session_id, + Some(&provider_id), + ) .await; if prepared.working_dir != working_dir { @@ -136,10 +145,14 @@ pub(super) async fn prepare_session_inner( .await .map_err(|error| format!("Failed to load Goose session: {error:?}"))?; if let Some(models) = &response.models { - dispatcher.emit_model_state(&local_session_id, models); + dispatcher.emit_model_state(&local_session_id, Some(&provider_id), models); } if let Some(options) = &response.config_options { - dispatcher.emit_model_state_from_options(&local_session_id, options); + dispatcher.emit_model_state_from_options( + &local_session_id, + Some(&provider_id), + options, + ); } let mut guard = state.lock().await; @@ -159,8 +172,11 @@ pub(super) async fn prepare_session_inner( .map_err(|error| { format!("Failed to update provider via Goose ACP: {error:?}") })?; - dispatcher - .emit_model_state_from_options(&local_session_id, &response.config_options); + dispatcher.emit_model_state_from_options( + &local_session_id, + Some(&provider_id), + &response.config_options, + ); let mut guard = state.lock().await; if let Some(session) = guard.sessions.get_mut(&composite_key) { @@ -173,7 +189,7 @@ pub(super) async fn prepare_session_inner( let goose_session_id = if let Some(existing_id) = existing_agent_session_id { dispatcher - .bind_session(&existing_id, &local_session_id) + .bind_session(&existing_id, &local_session_id, Some(&provider_id)) .await; let response = connection @@ -185,10 +201,14 @@ pub(super) async fn prepare_session_inner( .map_err(|error| format!("Failed to load Goose session: {error:?}"))?; if let Some(models) = &response.models { - dispatcher.emit_model_state(&local_session_id, models); + dispatcher.emit_model_state(&local_session_id, Some(&provider_id), models); } if let Some(options) = &response.config_options { - dispatcher.emit_model_state_from_options(&local_session_id, options); + dispatcher.emit_model_state_from_options( + &local_session_id, + Some(&provider_id), + options, + ); } let loaded_provider_id = response @@ -206,8 +226,11 @@ pub(super) async fn prepare_session_inner( .map_err(|error| { format!("Failed to update provider via Goose ACP: {error:?}") })?; - dispatcher - .emit_model_state_from_options(&local_session_id, &response.config_options); + dispatcher.emit_model_state_from_options( + &local_session_id, + Some(&provider_id), + &response.config_options, + ); } existing_id @@ -229,13 +252,19 @@ pub(super) async fn prepare_session_inner( let new_id = response.session_id.to_string(); - dispatcher.bind_session(&new_id, &local_session_id).await; + dispatcher + .bind_session(&new_id, &local_session_id, Some(&provider_id)) + .await; if let Some(models) = &response.models { - dispatcher.emit_model_state(&local_session_id, models); + dispatcher.emit_model_state(&local_session_id, Some(&provider_id), models); } if let Some(options) = &response.config_options { - dispatcher.emit_model_state_from_options(&local_session_id, options); + dispatcher.emit_model_state_from_options( + &local_session_id, + Some(&provider_id), + options, + ); } new_id @@ -267,91 +296,6 @@ pub(super) async fn prepare_session_inner( Ok(goose_session_id) } -async fn take_cancel_requested(state: &Arc>, composite_key: &str) -> bool { - let mut guard = state.lock().await; - guard.take_cancel_requested(composite_key) -} - -async fn clear_cancel_requested(state: &Arc>, composite_key: &str) { - let mut guard = state.lock().await; - guard.pending_cancels.remove(composite_key); -} - -#[allow(clippy::too_many_arguments)] -pub(super) async fn send_prompt_inner( - connection: &Arc, - dispatcher: &Arc, - state: &Arc>, - composite_key: String, - local_session_id: String, - provider_id: String, - working_dir: PathBuf, - existing_agent_session_id: Option, - writer: Arc, - prompt: String, - images: Vec<(String, String)>, -) -> Result<(), String> { - let goose_session_id = match prepare_session_inner( - connection, - dispatcher, - state, - PrepareSessionInput { - composite_key: composite_key.clone(), - local_session_id: local_session_id.clone(), - provider_id, - working_dir, - existing_agent_session_id, - }, - ) - .await - { - Ok(goose_session_id) => goose_session_id, - Err(error) => { - clear_cancel_requested(state, &composite_key).await; - return Err(error); - } - }; - - if take_cancel_requested(state, &composite_key).await { - return Ok(()); - } - - dispatcher - .attach_writer(&goose_session_id, &local_session_id, writer.clone()) - .await; - - if dispatcher.is_canceled(&goose_session_id).await - || take_cancel_requested(state, &composite_key).await - { - dispatcher.clear_writer(&goose_session_id).await; - return Ok(()); - } - - let mut content_blocks = vec![AcpContentBlock::Text(TextContent::new(prompt))]; - for (data, mime_type) in &images { - content_blocks.push(AcpContentBlock::Image(ImageContent::new( - data.as_str(), - mime_type.as_str(), - ))); - } - - let result = connection - .prompt(PromptRequest::new(goose_session_id.clone(), content_blocks)) - .await - .map(|_| ()) - .map_err(|error| format!("Prompt failed via Goose ACP: {error:?}")); - - let canceled = dispatcher.is_canceled(&goose_session_id).await; - dispatcher.clear_writer(&goose_session_id).await; - clear_cancel_requested(state, &composite_key).await; - - if result.is_ok() && !canceled { - writer.finalize().await; - } - - result -} - /// List all sessions known to the goose binary. pub(super) async fn list_sessions_inner( connection: &Arc, @@ -396,7 +340,7 @@ pub(super) async fn load_session_inner( working_dir: PathBuf, ) -> Result<(), String> { dispatcher - .bind_session(goose_session_id, local_session_id) + .bind_session(goose_session_id, local_session_id, None) .await; let response = connection @@ -411,10 +355,10 @@ pub(super) async fn load_session_inner( dispatcher.finalize_replay(goose_session_id).await; if let Some(models) = &response.models { - dispatcher.emit_model_state(local_session_id, models); + dispatcher.emit_model_state(local_session_id, None, models); } if let Some(options) = &response.config_options { - dispatcher.emit_model_state_from_options(local_session_id, options); + dispatcher.emit_model_state_from_options(local_session_id, None, options); } // Register the session so future prompts reuse this goose session @@ -467,32 +411,61 @@ pub(super) async fn cancel_session_inner( Ok(true) } -#[cfg(test)] -mod tests { - use std::collections::{HashMap, HashSet}; +pub(super) async fn set_model_inner( + connection: &Arc, + dispatcher: &Arc, + state: &Arc>, + local_session_id: &str, + model_id: &str, +) -> Result<(), String> { + let prepared_sessions = { + let guard = state.lock().await; + guard + .sessions + .iter() + .filter_map(|(composite_key, session)| { + let (session_id, _) = split_composite_key(composite_key); + (session_id == local_session_id).then_some(( + composite_key.clone(), + session.goose_session_id.clone(), + session.provider_id.clone(), + )) + }) + .collect::>() + }; - use super::{needs_provider_update, ManagerState}; - - #[test] - fn provider_update_detects_switch_back_to_goose() { - assert!(needs_provider_update(Some("openai"), "goose")); - assert!(needs_provider_update(Some("claude-acp"), "goose")); - assert!(!needs_provider_update(Some("goose"), "goose")); - assert!(needs_provider_update(None, "goose")); + if prepared_sessions.is_empty() { + return Err(format!( + "Failed to update model for session '{local_session_id}': no prepared ACP session" + )); } - #[test] - fn pending_cancel_is_consumed_once() { - let mut state = ManagerState { - sessions: HashMap::new(), - op_locks: HashMap::new(), - pending_cancels: HashSet::new(), - preparing_sessions: HashSet::new(), + let mut updated_goose_sessions = HashSet::new(); + for (composite_key, goose_session_id, provider_id) in prepared_sessions { + if !updated_goose_sessions.insert(goose_session_id.clone()) { + continue; + } + + let session_lock = { + let mut guard = state.lock().await; + guard.session_lock(&composite_key) }; + let _lock_guard = session_lock.lock().await; - state.mark_cancel_requested("session-1"); - - assert!(state.take_cancel_requested("session-1")); - assert!(!state.take_cancel_requested("session-1")); + let response = connection + .set_session_config_option(SetSessionConfigOptionRequest::new( + goose_session_id, + "model", + model_id, + )) + .await + .map_err(|error| format!("Failed to update model via Goose ACP: {error:?}"))?; + dispatcher.emit_model_state_from_options( + local_session_id, + Some(&provider_id), + &response.config_options, + ); } + + Ok(()) } diff --git a/ui/goose2/src-tauri/src/services/acp/manager/session_ops/prompt_ops.rs b/ui/goose2/src-tauri/src/services/acp/manager/session_ops/prompt_ops.rs new file mode 100644 index 0000000000..f9997844d9 --- /dev/null +++ b/ui/goose2/src-tauri/src/services/acp/manager/session_ops/prompt_ops.rs @@ -0,0 +1,103 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use acp_client::MessageWriter; +use agent_client_protocol::{ + Agent, ClientSideConnection, ContentBlock as AcpContentBlock, ImageContent, PromptRequest, + TextContent, +}; +use tokio::sync::Mutex; + +use super::super::dispatcher::SessionEventDispatcher; +use super::{prepare_session_inner, ManagerState, PrepareSessionInput}; + +async fn take_cancel_requested(state: &Arc>, composite_key: &str) -> bool { + let mut guard = state.lock().await; + guard.take_cancel_requested(composite_key) +} + +async fn clear_cancel_requested(state: &Arc>, composite_key: &str) { + let mut guard = state.lock().await; + guard.pending_cancels.remove(composite_key); +} + +#[allow(clippy::too_many_arguments)] +pub(in super::super) async fn send_prompt_inner( + connection: &Arc, + dispatcher: &Arc, + state: &Arc>, + composite_key: String, + local_session_id: String, + provider_id: String, + working_dir: PathBuf, + existing_agent_session_id: Option, + writer: Arc, + prompt: String, + images: Vec<(String, String)>, +) -> Result<(), String> { + let provider_id_for_writer = provider_id.clone(); + let goose_session_id = match prepare_session_inner( + connection, + dispatcher, + state, + PrepareSessionInput { + composite_key: composite_key.clone(), + local_session_id: local_session_id.clone(), + provider_id, + working_dir, + existing_agent_session_id, + }, + ) + .await + { + Ok(goose_session_id) => goose_session_id, + Err(error) => { + clear_cancel_requested(state, &composite_key).await; + return Err(error); + } + }; + + if take_cancel_requested(state, &composite_key).await { + return Ok(()); + } + + dispatcher + .attach_writer( + &goose_session_id, + &local_session_id, + Some(&provider_id_for_writer), + writer.clone(), + ) + .await; + + if dispatcher.is_canceled(&goose_session_id).await + || take_cancel_requested(state, &composite_key).await + { + dispatcher.clear_writer(&goose_session_id).await; + return Ok(()); + } + + let mut content_blocks = vec![AcpContentBlock::Text(TextContent::new(prompt))]; + for (data, mime_type) in &images { + content_blocks.push(AcpContentBlock::Image(ImageContent::new( + data.as_str(), + mime_type.as_str(), + ))); + } + + let result = connection + .prompt(PromptRequest::new(goose_session_id.clone(), content_blocks)) + .await + .map(|_| ()) + .map_err(|error| format!("Prompt failed via Goose ACP: {error:?}")); + + let canceled = dispatcher.is_canceled(&goose_session_id).await; + dispatcher.clear_writer(&goose_session_id).await; + clear_cancel_requested(state, &composite_key).await; + + if result.is_ok() && !canceled { + writer.finalize().await; + } + + result +} diff --git a/ui/goose2/src-tauri/src/services/acp/manager/session_ops/tests.rs b/ui/goose2/src-tauri/src/services/acp/manager/session_ops/tests.rs new file mode 100644 index 0000000000..2131081040 --- /dev/null +++ b/ui/goose2/src-tauri/src/services/acp/manager/session_ops/tests.rs @@ -0,0 +1,36 @@ +use std::collections::{HashMap, HashSet}; + +use super::{needs_provider_update, ManagerState}; +use crate::services::acp::split_composite_key; + +#[test] +fn provider_update_detects_switch_back_to_goose() { + assert!(needs_provider_update(Some("openai"), "goose")); + assert!(needs_provider_update(Some("claude-acp"), "goose")); + assert!(!needs_provider_update(Some("goose"), "goose")); + assert!(needs_provider_update(None, "goose")); +} + +#[test] +fn pending_cancel_is_consumed_once() { + let mut state = ManagerState { + sessions: HashMap::new(), + op_locks: HashMap::new(), + pending_cancels: HashSet::new(), + preparing_sessions: HashSet::new(), + }; + + state.mark_cancel_requested("session-1"); + + assert!(state.take_cancel_requested("session-1")); + assert!(!state.take_cancel_requested("session-1")); +} + +#[test] +fn split_composite_key_extracts_local_session_id() { + assert_eq!( + split_composite_key("session-1__persona-1"), + ("session-1", Some("persona-1")) + ); + assert_eq!(split_composite_key("session-1"), ("session-1", None)); +} diff --git a/ui/goose2/src-tauri/src/services/acp/manager/thread.rs b/ui/goose2/src-tauri/src/services/acp/manager/thread.rs new file mode 100644 index 0000000000..da3d4ecd6c --- /dev/null +++ b/ui/goose2/src-tauri/src/services/acp/manager/thread.rs @@ -0,0 +1,169 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use agent_client_protocol::{ + Agent, ClientSideConnection, Implementation, InitializeRequest, ProtocolVersion, +}; +use futures::{SinkExt, StreamExt}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +use crate::services::acp::goose_serve::{GooseServeProcess, WS_BRIDGE_BUFFER_BYTES}; + +use super::command_dispatch; +use super::dispatcher::{SessionEventDispatcher, SessionRoute}; +use super::ManagerCommand; + +pub(super) fn run_manager_thread( + app_handle: tauri::AppHandle, + command_rx: mpsc::UnboundedReceiver, + ready_tx: oneshot::Sender>, +) { + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + let _ = ready_tx.send(Err(format!( + "Failed to build Goose ACP manager runtime: {error}" + ))); + return; + } + }; + + let local = tokio::task::LocalSet::new(); + local.block_on(&runtime, async move { + let setup_result = async { + GooseServeProcess::start().await?; + let process = GooseServeProcess::get()?; + let ws_stream = connect_async(process.ws_url()) + .await + .map(|(stream, _)| stream) + .map_err(|error| format!("Failed to connect to goose serve: {error}"))?; + + let (mut ws_writer, mut ws_reader) = ws_stream.split(); + let (connection_outgoing, bridge_outgoing_reader) = + tokio::io::duplex(WS_BRIDGE_BUFFER_BYTES); + let (bridge_incoming_writer, connection_incoming) = + tokio::io::duplex(WS_BRIDGE_BUFFER_BYTES); + + tokio::task::spawn_local(async move { + let mut reader = BufReader::new(bridge_outgoing_reader); + let mut line = String::new(); + + loop { + line.clear(); + let bytes_read = match reader.read_line(&mut line).await { + Ok(bytes_read) => bytes_read, + Err(error) => { + log::error!("Failed to read ACP output for Goose WebSocket: {error}"); + break; + } + }; + if bytes_read == 0 { + break; + } + + let payload = line.trim_end_matches(['\r', '\n']); + if payload.is_empty() { + continue; + } + + if let Err(error) = ws_writer.send(Message::Text(payload.to_string())).await { + log::error!("Failed to write Goose WebSocket frame: {error}"); + break; + } + } + + let _ = ws_writer.close().await; + }); + + tokio::task::spawn_local(async move { + let mut writer = bridge_incoming_writer; + + while let Some(message) = ws_reader.next().await { + match message { + Ok(Message::Text(text)) => { + if let Err(error) = writer.write_all(text.as_bytes()).await { + log::error!( + "Failed to forward Goose WebSocket payload to ACP client: {error}" + ); + break; + } + if let Err(error) = writer.write_all(b"\n").await { + log::error!( + "Failed to delimit Goose WebSocket payload for ACP client: {error}" + ); + break; + } + if let Err(error) = writer.flush().await { + log::error!("Failed to flush Goose WebSocket payload: {error}"); + break; + } + } + Ok(Message::Close(_)) => break, + Ok(Message::Binary(_)) + | Ok(Message::Ping(_)) + | Ok(Message::Pong(_)) + | Ok(Message::Frame(_)) => {} + Err(error) => { + log::error!("Goose WebSocket read failed: {error}"); + break; + } + } + } + + let _ = writer.shutdown().await; + }); + + let routes = Arc::new(Mutex::new(HashMap::::new())); + let dispatcher = Arc::new(SessionEventDispatcher::new( + app_handle.clone(), + Arc::clone(&routes), + )); + let (connection, io_future) = ClientSideConnection::new( + dispatcher.clone(), + connection_outgoing.compat_write(), + connection_incoming.compat(), + |future| { + tokio::task::spawn_local(future); + }, + ); + let connection = Arc::new(connection); + + tokio::task::spawn_local(async move { + if let Err(error) = io_future.await { + log::error!("Goose ACP IO error: {error:?}"); + } + }); + + connection + .initialize( + InitializeRequest::new(ProtocolVersion::LATEST) + .client_info(Implementation::new("goose2", env!("CARGO_PKG_VERSION"))), + ) + .await + .map_err(|error| format!("Goose ACP initialize failed: {error:?}"))?; + + Ok::<_, String>((connection, dispatcher)) + } + .await; + + let (connection, dispatcher) = match setup_result { + Ok(parts) => { + let _ = ready_tx.send(Ok(())); + parts + } + Err(error) => { + let _ = ready_tx.send(Err(error)); + return; + } + }; + + command_dispatch::dispatch_commands(command_rx, connection, dispatcher).await; + }); +} diff --git a/ui/goose2/src-tauri/src/services/acp/mod.rs b/ui/goose2/src-tauri/src/services/acp/mod.rs index 1c2011eeba..83e20db86e 100644 --- a/ui/goose2/src-tauri/src/services/acp/mod.rs +++ b/ui/goose2/src-tauri/src/services/acp/mod.rs @@ -82,6 +82,7 @@ impl AcpService { let writer_impl = Arc::new(TauriMessageWriter::new( app_handle.clone(), session_id.clone(), + Some(provider_id.clone()), persona_id.clone(), persona_name.clone(), )); diff --git a/ui/goose2/src-tauri/src/services/acp/payloads.rs b/ui/goose2/src-tauri/src/services/acp/payloads.rs index 79e44da127..9c5b47368d 100644 --- a/ui/goose2/src-tauri/src/services/acp/payloads.rs +++ b/ui/goose2/src-tauri/src/services/acp/payloads.rs @@ -1,3 +1,4 @@ +use acp_client::SessionModelState; use serde::Serialize; /// Payload for the `acp:message_created` event. @@ -63,10 +64,30 @@ pub(crate) struct SessionInfoPayload { pub title: Option, } +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ModelOption { + pub id: String, + pub name: String, +} + #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ModelStatePayload { pub session_id: String, + pub provider_id: Option, pub current_model_id: String, pub current_model_name: Option, + pub available_models: Vec, +} + +pub(crate) fn model_options_from_state(state: &SessionModelState) -> Vec { + state + .available_models + .iter() + .map(|model| ModelOption { + id: model.model_id.to_string(), + name: model.name.clone(), + }) + .collect() } diff --git a/ui/goose2/src-tauri/src/services/acp/writer.rs b/ui/goose2/src-tauri/src/services/acp/writer.rs index 1c9f025d08..e693f4b1a0 100644 --- a/ui/goose2/src-tauri/src/services/acp/writer.rs +++ b/ui/goose2/src-tauri/src/services/acp/writer.rs @@ -4,8 +4,8 @@ use tauri::Emitter; use acp_client::{MessageWriter, SessionInfoUpdate, SessionModelState}; use super::payloads::{ - DonePayload, MessageCreatedPayload, ModelStatePayload, SessionInfoPayload, TextPayload, - ToolCallPayload, ToolResultPayload, ToolTitlePayload, + model_options_from_state, DonePayload, MessageCreatedPayload, ModelStatePayload, + SessionInfoPayload, TextPayload, ToolCallPayload, ToolResultPayload, ToolTitlePayload, }; /// A [`MessageWriter`] implementation that streams ACP output to the frontend @@ -14,14 +14,15 @@ use super::payloads::{ pub struct TauriMessageWriter { app_handle: tauri::AppHandle, session_id: String, + provider_id: Option, assistant_message_id: String, } impl TauriMessageWriter { - /// Create a new writer that emits events for the given session. pub fn new( app_handle: tauri::AppHandle, session_id: String, + provider_id: Option, persona_id: Option, persona_name: Option, ) -> Self { @@ -40,6 +41,7 @@ impl TauriMessageWriter { Self { app_handle, session_id, + provider_id, assistant_message_id, } } @@ -139,12 +141,15 @@ impl MessageWriter for TauriMessageWriter { .iter() .find(|m| m.model_id == state.current_model_id) .map(|m| m.name.clone()); + let available_models = model_options_from_state(state); let _ = self.app_handle.emit( "acp:model_state", ModelStatePayload { session_id: self.session_id.clone(), + provider_id: self.provider_id.clone(), current_model_id: state.current_model_id.to_string(), current_model_name, + available_models, }, ); } diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index 878730736e..9a9921ca83 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -1,17 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Sidebar } from "@/features/sidebar/ui/Sidebar"; import { StatusBar } from "@/features/status/ui/StatusBar"; -import { HomeScreen } from "@/features/home/ui/HomeScreen"; -import { ChatView } from "@/features/chat/ui/ChatView"; import type { PastedImage } from "@/shared/types/messages"; -import { SkillsView } from "@/features/skills/ui/SkillsView"; -import { AgentsView } from "@/features/agents/ui/AgentsView"; -import { ProjectsView } from "@/features/projects/ui/ProjectsView"; -import { SessionHistoryView } from "@/features/sessions/ui/SessionHistoryView"; import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog"; import { archiveProject } from "@/features/projects/api/projects"; import type { ProjectInfo } from "@/features/projects/api/projects"; import { SettingsModal } from "@/features/settings/ui/SettingsModal"; +import type { SectionId } from "@/features/settings/ui/SettingsModal"; import { TopBar } from "./ui/TopBar"; import { useChatStore } from "@/features/chat/stores/chatStore"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; @@ -21,6 +16,7 @@ import { useProjectStore } from "@/features/projects/stores/projectStore"; import { findExistingDraft } from "@/features/chat/lib/newChat"; import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle"; import { useAppStartup } from "./hooks/useAppStartup"; +import { AppShellContent } from "./ui/AppShellContent"; export type AppView = | "home" @@ -41,6 +37,8 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const [sidebarWidth, setSidebarWidth] = useState(SIDEBAR_DEFAULT_WIDTH); const [isResizing, setIsResizing] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsInitialSection, setSettingsInitialSection] = + useState("appearance"); const [createProjectOpen, setCreateProjectOpen] = useState(false); const [createProjectInitialWorkingDir, setCreateProjectInitialWorkingDir] = useState(null); @@ -246,6 +244,10 @@ export function AppShell({ children }: { children?: React.ReactNode }) { }, [chatStore, sessionStore, cleanupEmptyDraft], ); + const openSettings = useCallback((section: SectionId = "appearance") => { + setSettingsInitialSection(section); + setSettingsOpen(true); + }, []); const handleArchiveChat = useCallback( async (sessionId: string) => { @@ -436,49 +438,12 @@ export function AppShell({ children }: { children?: React.ReactNode }) { }, [clearActiveSession]); const activeSessionPersonaId = activeSession?.personaId; - - const renderContent = () => { - switch (activeView) { - case "skills": - return ; - case "agents": - return ; - case "projects": - return ; - case "session-history": - return ( - - ); - case "chat": - case "home": - return activeSession ? ( - { - setPendingInitialMessage(undefined); - setPendingInitialImages(undefined); - setHomeSelectedProvider(undefined); - setHomeSelectedPersonaId(undefined); - }} - /> - ) : ( - - ); - } - }; + const handleInitialMessageConsumed = useCallback(() => { + setPendingInitialMessage(undefined); + setPendingInitialImages(undefined); + setHomeSelectedProvider(undefined); + setHomeSelectedPersonaId(undefined); + }, []); const editingProjectProp = useMemo( () => @@ -501,7 +466,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { return (
- setSettingsOpen(true)} /> + openSettings()} />
- {children ?? renderContent()} + {children ?? ( + + )}
@@ -562,7 +544,12 @@ export function AppShell({ children }: { children?: React.ReactNode }) { />
- {settingsOpen && setSettingsOpen(false)} />} + {settingsOpen && ( + setSettingsOpen(false)} + /> + )} Promise; + onCreateProject: (options?: { + initialWorkingDir?: string | null; + onCreated?: (projectId: string) => void; + }) => void; + onHomeStartChat: ( + initialMessage?: string, + providerId?: string, + personaId?: string, + projectId?: string | null, + images?: PastedImage[], + ) => void; + onInitialMessageConsumed: () => void; + onRenameChat: (sessionId: string, nextTitle: string) => void; + onSelectSession: (sessionId: string) => void; + onStartChatFromProject: (project: ProjectInfo) => void; +} + +export function AppShellContent({ + activeView, + activeSession, + activeSessionPersonaId, + homeSelectedProvider, + homeSelectedPersonaId, + pendingInitialMessage, + pendingInitialImages, + onArchiveChat, + onCreateProject, + onHomeStartChat, + onInitialMessageConsumed, + onRenameChat, + onSelectSession, + onStartChatFromProject, +}: AppShellContentProps) { + switch (activeView) { + case "skills": + return ; + case "agents": + return ; + case "projects": + return ; + case "session-history": + return ( + + ); + case "chat": + case "home": + return activeSession ? ( + + ) : ( + + ); + } +} diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useAcpStream.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useAcpStream.test.ts index 9d711a4d81..a9f2f01aa0 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useAcpStream.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useAcpStream.test.ts @@ -76,6 +76,7 @@ describe("useAcpStream", () => { ], activeSessionId: sessionId, isLoading: false, + modelsBySession: {}, }); }); @@ -198,6 +199,75 @@ describe("useAcpStream", () => { expect(listeners.size).toBe(0); }); + it("stores model state for the targeted session", async () => { + renderHook(() => useAcpStream(true)); + await vi.waitFor(() => + expect(listeners.get("acp:model_state")).toBeDefined(), + ); + + act(() => { + emit("acp:model_state", { + sessionId, + currentModelId: "claude-sonnet-4", + currentModelName: "Claude Sonnet 4", + availableModels: [ + { id: "claude-sonnet-4", name: "Claude Sonnet 4" }, + { id: "gpt-4o", name: "GPT-4o" }, + ], + }); + }); + + expect( + useChatSessionStore.getState().getSession(sessionId)?.modelName, + ).toBe("Claude Sonnet 4"); + expect(useChatSessionStore.getState().getSession(sessionId)?.modelId).toBe( + "claude-sonnet-4", + ); + expect(useChatSessionStore.getState().getSessionModels(sessionId)).toEqual([ + { id: "claude-sonnet-4", name: "Claude Sonnet 4" }, + { id: "gpt-4o", name: "GPT-4o" }, + ]); + }); + + it("accepts providerless model state when the session already has a provider", async () => { + useChatSessionStore.setState((state) => ({ + ...state, + sessions: state.sessions.map((session) => + session.id === sessionId + ? { ...session, providerId: "openai" } + : session, + ), + })); + + renderHook(() => useAcpStream(true)); + await vi.waitFor(() => + expect(listeners.get("acp:model_state")).toBeDefined(), + ); + + act(() => { + emit("acp:model_state", { + sessionId, + currentModelId: "gpt-4.1", + currentModelName: "GPT-4.1", + availableModels: [ + { id: "gpt-4.1", name: "GPT-4.1" }, + { id: "gpt-4o", name: "GPT-4o" }, + ], + }); + }); + + expect( + useChatSessionStore.getState().getSession(sessionId)?.modelName, + ).toBe("GPT-4.1"); + expect(useChatSessionStore.getState().getSession(sessionId)?.modelId).toBe( + "gpt-4.1", + ); + expect(useChatSessionStore.getState().getSessionModels(sessionId)).toEqual([ + { id: "gpt-4.1", name: "GPT-4.1" }, + { id: "gpt-4o", name: "GPT-4o" }, + ]); + }); + it("creates the streaming assistant message from backend metadata", async () => { useChatStore.getState().setChatState(sessionId, "streaming"); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts index fa16bcd386..6d23b6e76b 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts @@ -2,14 +2,19 @@ import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useChatStore } from "../../stores/chatStore"; +import { useChatSessionStore } from "../../stores/chatSessionStore"; import type { Message } from "@/shared/types/messages"; const mockAcpSendMessage = vi.fn(); const mockAcpCancelSession = vi.fn(); +const mockAcpPrepareSession = vi.fn(); +const mockAcpSetModel = vi.fn(); vi.mock("@/shared/api/acp", () => ({ acpSendMessage: (...args: unknown[]) => mockAcpSendMessage(...args), acpCancelSession: (...args: unknown[]) => mockAcpCancelSession(...args), + acpPrepareSession: (...args: unknown[]) => mockAcpPrepareSession(...args), + acpSetModel: (...args: unknown[]) => mockAcpSetModel(...args), })); import { useChat } from "../useChat"; @@ -55,6 +60,14 @@ describe("useChat", () => { activeSessionId: null, isConnected: true, }); + useChatSessionStore.setState({ + sessions: [], + activeSessionId: null, + isLoading: false, + contextPanelOpenBySession: {}, + modelsBySession: {}, + modelCacheByProvider: {}, + }); useAgentStore.setState({ personas: [ { @@ -83,6 +96,8 @@ describe("useChat", () => { editingPersona: null, }); mockAcpCancelSession.mockResolvedValue(true); + mockAcpPrepareSession.mockResolvedValue(undefined); + mockAcpSetModel.mockResolvedValue(undefined); }); it("cancels the active override persona instead of the hook default persona", async () => { @@ -327,6 +342,47 @@ describe("useChat", () => { }); }); + it("prepares draft sessions before applying a selected model on first send", async () => { + useChatSessionStore.setState({ + sessions: [ + { + id: "session-1", + title: "New Chat", + providerId: "openai", + modelId: "gpt-4.1", + modelName: "GPT-4.1", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageCount: 0, + draft: true, + }, + ], + }); + + const { result } = renderHook(() => useChat("session-1", "openai")); + + await act(async () => { + await result.current.sendMessage("Hello"); + }); + + expect(mockAcpPrepareSession).toHaveBeenCalledWith("session-1", "openai", { + workingDir: undefined, + personaId: undefined, + }); + expect(mockAcpSetModel).toHaveBeenCalledWith("session-1", "gpt-4.1"); + expect(mockAcpSendMessage).toHaveBeenCalledWith( + "session-1", + "openai", + "Hello", + { + systemPrompt: undefined, + workingDir: undefined, + personaId: undefined, + personaName: undefined, + }, + ); + }); + it("appends an error message and removes the empty assistant placeholder when send fails", async () => { mockAcpSendMessage.mockRejectedValue( new Error("Working directory missing"), diff --git a/ui/goose2/src/features/chat/hooks/useAcpStream.ts b/ui/goose2/src/features/chat/hooks/useAcpStream.ts index 938a53d1bb..160d09eeaf 100644 --- a/ui/goose2/src/features/chat/hooks/useAcpStream.ts +++ b/ui/goose2/src/features/chat/hooks/useAcpStream.ts @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { useChatStore } from "../stores/chatStore"; import { useChatSessionStore } from "../stores/chatSessionStore"; +import type { ModelOption } from "../types"; import { isDefaultChatTitle } from "../lib/sessionTitle"; import type { Message, @@ -63,8 +64,10 @@ interface AcpSessionInfoPayload { interface AcpModelStatePayload { sessionId: string; + providerId?: string | null; currentModelId: string; currentModelName?: string; + availableModels: ModelOption[]; } interface AcpUsageUpdatePayload { @@ -120,16 +123,6 @@ function shouldTrackStreamingEvent( return runtime.chatState === "thinking" || runtime.chatState === "streaming"; } -/** - * Hook that listens to Tauri events for ACP streaming responses. - * - * Subscribes to `acp:text`, `acp:done`, `acp:tool_call`, `acp:tool_title`, - * and `acp:tool_result` events, updating whichever session the event targets. - * - * During session history replay, events are buffered in a module-level map - * (see `replayBuffer.ts`) and flushed as a single `setMessages()` call when - * loading completes, avoiding O(N²) re-renders. - */ export function useAcpStream(enabled: boolean): void { useEffect(() => { if (!enabled) return; @@ -137,14 +130,6 @@ export function useAcpStream(enabled: boolean): void { let active = true; const unlisteners: Promise[] = []; - // Flush replay buffers when a session transitions from loading → loaded. - // - // Re-entrancy note: setMessages() below triggers another Zustand state - // update, which re-invokes this subscriber synchronously. On the second - // invocation the `sid` has already been removed from both prev and current - // loadingSessionIds, so the for-of loop won't re-match it. Additionally, - // getAndDeleteReplayBuffer already deleted the buffer on the first pass, - // providing a second guard against double-flush. const unsubscribeFlush = useChatStore.subscribe((state, prevState) => { if (!active) return; for (const sid of prevState.loadingSessionIds) { @@ -461,11 +446,31 @@ export function useAcpStream(enabled: boolean): void { unlisteners.push( listen("acp:model_state", (event) => { if (!active) return; - const modelName = - event.payload.currentModelName ?? event.payload.currentModelId; - useChatSessionStore - .getState() - .updateSession(event.payload.sessionId, { modelName }); + const { + sessionId, + providerId, + currentModelId, + currentModelName, + availableModels, + } = event.payload; + const sessionStore = useChatSessionStore.getState(); + if (providerId) { + sessionStore.cacheModelsForProvider(providerId, availableModels); + } + const session = sessionStore.getSession(sessionId); + const sessionProvider = session?.providerId; + if (providerId && sessionProvider && providerId !== sessionProvider) { + console.debug( + `[acp:model_state] Ignoring event: provider mismatch (event: ${providerId}, session: ${sessionProvider})`, + ); + return; + } + const modelName = currentModelName ?? currentModelId; + sessionStore.setSessionModels(sessionId, availableModels); + sessionStore.updateSession(sessionId, { + modelId: currentModelId, + modelName, + }); }), ); diff --git a/ui/goose2/src/features/chat/hooks/useChat.ts b/ui/goose2/src/features/chat/hooks/useChat.ts index 5b3e4181f5..0648e7d54e 100644 --- a/ui/goose2/src/features/chat/hooks/useChat.ts +++ b/ui/goose2/src/features/chat/hooks/useChat.ts @@ -6,7 +6,12 @@ import { createUserMessage, } from "@/shared/types/messages"; import type { ChatState, TokenState } from "@/shared/types/chat"; -import { acpSendMessage, acpCancelSession } from "@/shared/api/acp"; +import { + acpSendMessage, + acpCancelSession, + acpPrepareSession, + acpSetModel, +} from "@/shared/api/acp"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { isDefaultChatTitle } from "../lib/sessionTitle"; import { findLastIndex } from "@/shared/lib/arrays"; @@ -160,6 +165,7 @@ export function useChat( const sessionStore = useChatSessionStore.getState(); const session = sessionStore.getSession(sessionId); const wasDraft = !!session?.draft; + const draftModelId = session?.modelId; if (wasDraft) { sessionStore.promoteDraft(sessionId); @@ -197,6 +203,14 @@ export function useChat( const systemPrompt = systemPromptOverride ?? agent?.systemPrompt ?? undefined; + if (wasDraft && draftModelId) { + await acpPrepareSession(sessionId, providerId, { + workingDir: workingDirOverride, + personaId: effectivePersonaInfo?.id, + }); + await acpSetModel(sessionId, draftModelId); + } + // Send via ACP — response streams back through Tauri events // which are handled by the global useAcpStream listener in AppShell. store.setChatState(sessionId, "streaming"); diff --git a/ui/goose2/src/features/chat/stores/__tests__/chatSessionStore.test.ts b/ui/goose2/src/features/chat/stores/__tests__/chatSessionStore.test.ts index 1ba9babb75..81e7d3e0d3 100644 --- a/ui/goose2/src/features/chat/stores/__tests__/chatSessionStore.test.ts +++ b/ui/goose2/src/features/chat/stores/__tests__/chatSessionStore.test.ts @@ -18,6 +18,7 @@ function resetStore() { activeSessionId: null, isLoading: false, contextPanelOpenBySession: {}, + modelsBySession: {}, }); } @@ -314,6 +315,54 @@ describe("chatSessionStore", () => { }); }); + describe("session models", () => { + it("stores models per session", () => { + const session = useChatSessionStore.getState().createDraftSession(); + + useChatSessionStore.getState().setSessionModels(session.id, [ + { id: "claude-sonnet-4", name: "Claude Sonnet 4" }, + { id: "gpt-4o", name: "GPT-4o" }, + ]); + + expect( + useChatSessionStore.getState().getSessionModels(session.id), + ).toEqual([ + { id: "claude-sonnet-4", name: "Claude Sonnet 4" }, + { id: "gpt-4o", name: "GPT-4o" }, + ]); + }); + + it("removes stored models when a draft session is removed", () => { + const session = useChatSessionStore.getState().createDraftSession(); + useChatSessionStore + .getState() + .setSessionModels(session.id, [ + { id: "claude-sonnet-4", name: "Claude Sonnet 4" }, + ]); + + useChatSessionStore.getState().removeDraft(session.id); + + expect( + useChatSessionStore.getState().getSessionModels(session.id), + ).toEqual([]); + }); + + it("removes stored models when a session is archived", async () => { + const session = useChatSessionStore.getState().createDraftSession(); + useChatSessionStore + .getState() + .setSessionModels(session.id, [ + { id: "claude-sonnet-4", name: "Claude Sonnet 4" }, + ]); + + await useChatSessionStore.getState().archiveSession(session.id); + + expect( + useChatSessionStore.getState().getSessionModels(session.id), + ).toEqual([]); + }); + }); + describe("archiveSession", () => { it("sets archivedAt on the session", async () => { const session = useChatSessionStore.getState().createDraftSession(); diff --git a/ui/goose2/src/features/chat/stores/chatSessionStore.ts b/ui/goose2/src/features/chat/stores/chatSessionStore.ts index ec220dbeb3..85b0ad1329 100644 --- a/ui/goose2/src/features/chat/stores/chatSessionStore.ts +++ b/ui/goose2/src/features/chat/stores/chatSessionStore.ts @@ -2,6 +2,9 @@ import { create } from "zustand"; import type { Session } from "@/shared/types/chat"; import { acpListSessions } from "@/shared/api/acp"; import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle"; +import type { ModelOption } from "../types"; + +const EMPTY_MODELS: ModelOption[] = []; // Extended session metadata used by the frontend session list export interface ChatSession { @@ -11,6 +14,7 @@ export interface ChatSession { agentId?: string; providerId?: string; personaId?: string; + modelId?: string; modelName?: string; createdAt: string; // ISO timestamp updatedAt: string; @@ -25,6 +29,8 @@ interface ChatSessionStoreState { activeSessionId: string | null; isLoading: boolean; contextPanelOpenBySession: Record; + modelsBySession: Record; + modelCacheByProvider: Record; } interface CreateSessionOpts { @@ -54,16 +60,26 @@ interface ChatSessionStoreActions { setActiveSession: (sessionId: string | null) => void; setContextPanelOpen: (sessionId: string, open: boolean) => void; + setSessionModels: (sessionId: string, models: ModelOption[]) => void; + switchSessionProvider: ( + sessionId: string, + providerId: string, + models: ModelOption[], + ) => void; + cacheModelsForProvider: (providerId: string, models: ModelOption[]) => void; + getCachedModels: (providerId: string) => ModelOption[]; // Helpers getSession: (id: string) => ChatSession | undefined; getActiveSession: () => ChatSession | null; getArchivedSessions: () => ChatSession[]; + getSessionModels: (sessionId: string) => ModelOption[]; } export type ChatSessionStore = ChatSessionStoreState & ChatSessionStoreActions; const SESSION_CACHE_STORAGE_KEY = "goose:chat-sessions"; +const MODEL_CACHE_STORAGE_KEY = "goose:model-cache"; function loadCachedSessions(): ChatSession[] { if (typeof window === "undefined") return []; @@ -107,6 +123,29 @@ function persistSessions(sessions: ChatSession[]): void { } } +function loadModelCache(): Record { + if (typeof window === "undefined") return {}; + try { + const stored = window.localStorage.getItem(MODEL_CACHE_STORAGE_KEY); + if (!stored) return {}; + const parsed = JSON.parse(stored); + return typeof parsed === "object" && parsed !== null + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function persistModelCache(cache: Record): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(MODEL_CACHE_STORAGE_KEY, JSON.stringify(cache)); + } catch { + // localStorage may be unavailable + } +} + /** Map a backend Session to the frontend ChatSession shape. */ export function sessionToChatSession(session: Session): ChatSession { return { @@ -116,6 +155,7 @@ export function sessionToChatSession(session: Session): ChatSession { projectId: session.projectId, providerId: session.providerId, personaId: session.personaId, + modelId: session.modelId, modelName: session.modelName, createdAt: session.createdAt, updatedAt: session.updatedAt, @@ -131,6 +171,8 @@ export const useChatSessionStore = create((set, get) => ({ activeSessionId: null, isLoading: false, contextPanelOpenBySession: {}, + modelsBySession: {}, + modelCacheByProvider: loadModelCache(), // Session lifecycle — local-only for now. // The goose binary creates the real ACP session on first prompt @@ -177,11 +219,14 @@ export const useChatSessionStore = create((set, get) => ({ const session = get().sessions.find((s) => s.id === id); if (!session?.draft) return; const { [id]: _, ...remainingPanelState } = get().contextPanelOpenBySession; + const remainingModels = { ...get().modelsBySession }; + delete remainingModels[id]; set((state) => ({ sessions: state.sessions.filter((s) => s.id !== id), activeSessionId: state.activeSessionId === id ? null : state.activeSessionId, contextPanelOpenBySession: remainingPanelState, + modelsBySession: remainingModels, })); }, @@ -258,12 +303,15 @@ export const useChatSessionStore = create((set, get) => ({ }, archiveSession: async (id) => { + const remainingModels = { ...get().modelsBySession }; + delete remainingModels[id]; set((state) => ({ sessions: state.sessions.map((s) => s.id === id ? { ...s, archivedAt: new Date().toISOString() } : s, ), activeSessionId: state.activeSessionId === id ? null : state.activeSessionId, + modelsBySession: remainingModels, })); persistSessions(get().sessions); }, @@ -291,6 +339,62 @@ export const useChatSessionStore = create((set, get) => ({ })); }, + setSessionModels: (sessionId, models) => { + set((state) => ({ + modelsBySession: { + ...state.modelsBySession, + [sessionId]: models, + }, + })); + }, + + switchSessionProvider: (sessionId, providerId, models) => { + set((state) => ({ + modelsBySession: { + ...state.modelsBySession, + [sessionId]: models, + }, + sessions: state.sessions.map((s) => + s.id === sessionId + ? { + ...s, + providerId, + modelId: models.length > 0 ? models[0].id : undefined, + modelName: + models.length > 0 + ? (models[0].displayName ?? models[0].name) + : undefined, + updatedAt: s.updatedAt, + } + : s, + ), + })); + persistSessions(get().sessions); + }, + + cacheModelsForProvider: (providerId, models) => { + if (models.length === 0) return; + const existing = get().modelCacheByProvider[providerId]; + if ( + existing && + existing.length === models.length && + existing.every((m, i) => m.id === models[i].id) + ) { + return; + } + set((state) => { + const updated = { + ...state.modelCacheByProvider, + [providerId]: models, + }; + persistModelCache(updated); + return { modelCacheByProvider: updated }; + }); + }, + + getCachedModels: (providerId) => + get().modelCacheByProvider[providerId] ?? EMPTY_MODELS, + // Helpers getSession: (id) => get().sessions.find((s) => s.id === id), @@ -299,6 +403,8 @@ export const useChatSessionStore = create((set, get) => ({ if (!activeSessionId) return null; return sessions.find((s) => s.id === activeSessionId) ?? null; }, - getArchivedSessions: () => get().sessions.filter((s) => !!s.archivedAt), + + getSessionModels: (sessionId) => + get().modelsBySession[sessionId] ?? EMPTY_MODELS, })); diff --git a/ui/goose2/src/features/chat/types.ts b/ui/goose2/src/features/chat/types.ts new file mode 100644 index 0000000000..e6fa552022 --- /dev/null +++ b/ui/goose2/src/features/chat/types.ts @@ -0,0 +1,6 @@ +export interface ModelOption { + id: string; + name: string; + displayName?: string; + provider?: string; +} diff --git a/ui/goose2/src/features/chat/ui/AgentModelPicker.tsx b/ui/goose2/src/features/chat/ui/AgentModelPicker.tsx new file mode 100644 index 0000000000..3dee58ea5f --- /dev/null +++ b/ui/goose2/src/features/chat/ui/AgentModelPicker.tsx @@ -0,0 +1,328 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { + IconCheck, + IconChevronDown, + IconChevronRight, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import type { AcpProvider } from "@/shared/api/acp"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { ScrollArea } from "@/shared/ui/scroll-area"; +import { Separator } from "@/shared/ui/separator"; +import { + formatProviderLabel, + getProviderIcon, +} from "@/shared/ui/icons/ProviderIcons"; +import type { ModelOption } from "../types"; + +interface AgentModelPickerProps { + agents: AcpProvider[]; + selectedAgentId: string; + onAgentChange: (agentId: string) => void; + currentModelId?: string | null; + currentModelName?: string | null; + availableModels: ModelOption[]; + onModelChange?: (modelId: string) => void; + loading?: boolean; + isCompact?: boolean; +} + +interface ModelGroup { + provider: string; + models: ModelOption[]; + hasSelectedModel: boolean; +} + +const MODEL_PROVIDER_MATCHERS: Array<[string, RegExp]> = [ + ["Anthropic", /claude|anthropic/i], + ["OpenAI", /(^|[\s-])(gpt|o1|o3|o4)([\s-]|$)|openai/i], + ["Google", /gemini|google/i], + ["Mistral", /mistral/i], + ["Meta", /llama|meta/i], + ["DeepSeek", /deepseek/i], + ["Qwen", /qwen/i], + ["Cohere", /cohere|command/i], +]; + +function getModelDisplayName(model: ModelOption) { + return model.displayName ?? model.name; +} + +function inferModelProvider(model: ModelOption) { + if (model.provider) { + return model.provider; + } + + const candidate = `${model.id} ${model.name}`; + for (const [provider, pattern] of MODEL_PROVIDER_MATCHERS) { + if (pattern.test(candidate)) { + return provider; + } + } + + return "Other"; +} + +function groupModels(models: ModelOption[], currentModelId: string | null) { + const grouped = new Map(); + + for (const model of models) { + const provider = inferModelProvider(model); + const existing = grouped.get(provider) ?? []; + existing.push(model); + grouped.set(provider, existing); + } + + const groups = Array.from(grouped.entries()) + .map(([provider, groupedModels]) => ({ + provider, + models: groupedModels, + hasSelectedModel: groupedModels.some((m) => m.id === currentModelId), + })) + .sort((left, right) => left.provider.localeCompare(right.provider)); + + return groups; +} + +function PickerItem({ + children, + onClick, + selected = false, + disabled = false, + className, +}: { + children: ReactNode; + onClick?: () => void; + selected?: boolean; + disabled?: boolean; + className?: string; +}) { + return ( + + ); +} + +export function AgentModelPicker({ + agents, + selectedAgentId, + onAgentChange, + currentModelId = null, + currentModelName = null, + availableModels, + onModelChange, + loading = false, + isCompact = false, +}: AgentModelPickerProps) { + const { t } = useTranslation("chat"); + const [open, setOpen] = useState(false); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + + const selectedAgentLabel = + agents.find((agent) => agent.id === selectedAgentId)?.label ?? + formatProviderLabel(selectedAgentId); + const groupedModels = useMemo( + () => groupModels(availableModels, currentModelId), + [availableModels, currentModelId], + ); + const hasModelInfo = currentModelName !== null || availableModels.length > 0; + const triggerModelLabel = hasModelInfo + ? (currentModelName ?? t("toolbar.loading")) + : null; + + useEffect(() => { + if (open) { + const selected = groupedModels.find((g) => g.hasSelectedModel); + if (selected) { + setExpandedGroups(new Set([selected.provider])); + } + } + }, [open, groupedModels]); + + const toggleGroup = (provider: string) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(provider)) { + next.delete(provider); + } else { + next.add(provider); + } + return next; + }); + }; + + const isGroupExpanded = (group: ModelGroup) => { + return expandedGroups.has(group.provider); + }; + + const handleAgentSelect = (agentId: string) => { + if (agentId !== selectedAgentId) { + onAgentChange(agentId); + } + }; + + const handleModelSelect = (modelId: string) => { + onModelChange?.(modelId); + setOpen(false); + }; + + return ( + + + + + +
+
+
+ {t("toolbar.agent")} +
+ +
+ {agents.map((agent) => { + const isSelected = agent.id === selectedAgentId; + + return ( + handleAgentSelect(agent.id)} + selected={isSelected} + > + + {getProviderIcon(agent.id, "size-4")} + + + {agent.label} + + {isSelected ? ( + + ) : null} + + ); + })} +
+
+
+ +
+
+ {t("toolbar.model")} +
+ {groupedModels.length > 0 ? ( + +
+ {groupedModels.map((group) => { + const expanded = isGroupExpanded(group); + + return ( +
+ + {expanded ? ( +
+ {group.models.map((model) => { + const modelName = getModelDisplayName(model); + + return ( + handleModelSelect(model.id)} + selected={model.id === currentModelId} + className="justify-between pl-6" + > +
+ {modelName} +
+ {model.id === currentModelId ? ( + + ) : null} +
+ ); + })} +
+ ) : group.hasSelectedModel ? ( +
+ {group.models + .filter((m) => m.id === currentModelId) + .map((model) => ( + handleModelSelect(model.id)} + selected + className="justify-between pl-6" + > +
+ {getModelDisplayName(model)} +
+ +
+ ))} +
+ ) : null} +
+ ); + })} +
+
+ ) : ( +
+
+ {currentModelName ? currentModelName : t("toolbar.loading")} +
+
+ )} +
+
+
+
+ ); +} diff --git a/ui/goose2/src/features/chat/ui/ChatContextPanel.tsx b/ui/goose2/src/features/chat/ui/ChatContextPanel.tsx new file mode 100644 index 0000000000..dcf723a756 --- /dev/null +++ b/ui/goose2/src/features/chat/ui/ChatContextPanel.tsx @@ -0,0 +1,98 @@ +import { + IconLayoutSidebarRight, + IconLayoutSidebarRightFilled, +} from "@tabler/icons-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { Button } from "@/shared/ui/button"; +import { ContextPanel } from "./ContextPanel"; + +const CP_PAD = 12; +const CP_TOTAL_W = 340 + CP_PAD * 2; +const CP_TOGGLE_RIGHT = CP_PAD + 12; +const CP_TOGGLE_TOP = CP_PAD + 10; +const CP_FADE_S = 0.15; +const CP_REFLOW_MS = 200; + +interface ChatContextPanelProps { + activeSessionId: string; + isOpen: boolean; + label: string; + project?: { + name?: string; + color?: string; + workingDirs?: string[]; + } | null; + setOpen: (sessionId: string, open: boolean) => void; +} + +export function ChatContextPanel({ + activeSessionId, + isOpen, + label, + project, + setOpen, +}: ChatContextPanelProps) { + const shouldReduceMotion = useReducedMotion(); + const fadeTransition = { duration: shouldReduceMotion ? 0 : CP_FADE_S }; + const reflowDuration = shouldReduceMotion ? 0 : CP_REFLOW_MS; + + return ( + <> +
+ + {isOpen ? ( + + + + ) : null} + +
+ +
+ +
+ + ); +} diff --git a/ui/goose2/src/features/chat/ui/ChatInput.tsx b/ui/goose2/src/features/chat/ui/ChatInput.tsx index e454803edc..8407933196 100644 --- a/ui/goose2/src/features/chat/ui/ChatInput.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInput.tsx @@ -18,13 +18,7 @@ import { ImageLightbox } from "@/shared/ui/ImageLightbox"; import type { PastedImage } from "@/shared/types/messages"; import { resizeImage } from "../lib/resizeImage"; import { useImageDropTarget } from "../hooks/useImageDropTarget"; - -export interface ModelOption { - id: string; - name: string; - displayName?: string; - provider?: string; -} +import type { ModelOption } from "../types"; export interface ProjectOption { id: string; @@ -54,6 +48,7 @@ interface ChatInputProps { selectedProvider?: string; onProviderChange?: (providerId: string) => void; // Model + currentModelId?: string | null; currentModel?: string; availableModels?: ModelOption[]; onModelChange?: (modelId: string) => void; @@ -141,7 +136,8 @@ export function ChatInput({ providersLoading = false, selectedProvider = "goose", onProviderChange, - currentModel = "Claude Sonnet 4", + currentModelId = null, + currentModel, availableModels = [], onModelChange, selectedProjectId = null, @@ -363,6 +359,8 @@ export function ChatInput({ providers.find((p) => p.id === selectedProvider)?.label ?? formatProviderLabel(selectedProvider); const agentDisplayName = activePersona?.displayName ?? providerDisplayName; + const resolvedCurrentModel = + currentModel ?? availableModels[0]?.displayName ?? availableModels[0]?.name; const effectivePlaceholder = t("input.placeholder", { agent: agentDisplayName, }); @@ -475,7 +473,8 @@ export function ChatInput({ providersLoading={providersLoading} selectedProvider={selectedProvider} onProviderChange={(id) => onProviderChange?.(id)} - currentModel={currentModel} + currentModelId={currentModelId} + currentModel={resolvedCurrentModel} availableModels={availableModels} onModelChange={onModelChange} selectedProjectId={selectedProjectId} diff --git a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx index 64f1efbb81..666f99cbf6 100644 --- a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx @@ -1,10 +1,7 @@ -import { Mic, ChevronDown, Check, ArrowUp, Square } from "lucide-react"; +import { useMemo } from "react"; +import { Mic, ArrowUp, Square } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useLocaleFormatting } from "@/shared/i18n"; -import { - formatProviderLabel, - getProviderIcon, -} from "@/shared/ui/icons/ProviderIcons"; import { IconLibraryPlusFilled } from "@tabler/icons-react"; import type { AcpProvider } from "@/shared/api/acp"; import type { Persona } from "@/shared/types/agents"; @@ -12,17 +9,17 @@ import { cn } from "@/shared/lib/cn"; import { ChatInputSelector } from "./ChatInputSelector"; import { ContextRing } from "./ContextRing"; import { PersonaPicker } from "./PersonaPicker"; -import type { ModelOption, ProjectOption } from "./ChatInput"; -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, -} from "@/shared/ui/dropdown-menu"; +import type { ProjectOption } from "./ChatInput"; import { Button } from "@/shared/ui/button"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/shared/ui/tooltip"; +import { AgentModelPicker } from "./AgentModelPicker"; +import type { ModelOption } from "../types"; +import { formatProviderLabel } from "@/shared/ui/icons/ProviderIcons"; +import { useAgentProviderStatus } from "@/features/providers/hooks/useAgentProviderStatus"; +import { + getCatalogEntry, + resolveAgentProviderCatalogIdStrict, +} from "@/features/providers/providerCatalog"; const NO_PROJECT_VALUE = "__no_project__"; const CREATE_PROJECT_VALUE = "__create_project__"; @@ -52,7 +49,8 @@ interface ChatInputToolbarProps { selectedProvider: string; onProviderChange: (providerId: string) => void; // Model - currentModel: string; + currentModelId?: string | null; + currentModel?: string; availableModels: ModelOption[]; onModelChange?: (modelId: string) => void; // Project @@ -84,6 +82,7 @@ export function ChatInputToolbar({ providersLoading, selectedProvider, onProviderChange, + currentModelId, currentModel, availableModels, onModelChange, @@ -102,17 +101,35 @@ export function ChatInputToolbar({ }: ChatInputToolbarProps) { const { t } = useTranslation("chat"); const { formatNumber } = useLocaleFormatting(); - const availableProviderItems = - providers.length > 0 - ? providers - : selectedProvider - ? [ - { - id: selectedProvider, - label: formatProviderLabel(selectedProvider), - }, - ] - : []; + const { readyAgentIds } = useAgentProviderStatus(); + + const agentProviders = useMemo(() => { + const seen = new Set(); + const connected: AcpProvider[] = []; + for (const p of providers) { + const catalogId = resolveAgentProviderCatalogIdStrict(p.id); + if ( + catalogId === null || + !readyAgentIds.has(catalogId) || + seen.has(catalogId) + ) + continue; + seen.add(catalogId); + connected.push({ + id: p.id, + label: getCatalogEntry(catalogId)?.displayName ?? p.label, + }); + } + if (connected.length > 0) return connected; + return [ + { + id: selectedProvider, + label: + getCatalogEntry(selectedProvider)?.displayName ?? + formatProviderLabel(selectedProvider), + }, + ]; + }, [providers, readyAgentIds, selectedProvider]); const selectedProject = availableProjects.find( (project) => project.id === selectedProjectId, ); @@ -120,9 +137,6 @@ export function ChatInputToolbar({ const projectTitle = selectedProject?.workingDirs.length ? selectedProject.workingDirs.join(", ") : undefined; - const providerLabel = - availableProviderItems.find((provider) => provider.id === selectedProvider) - ?.label ?? formatProviderLabel(selectedProvider); const handleProjectValueChange = (value: string) => { if (value === CREATE_PROJECT_VALUE) { @@ -137,76 +151,20 @@ export function ChatInputToolbar({
{/* Left side: pickers */}
- {(availableProviderItems.length > 0 || providersLoading) && ( - ({ - value: provider.id, - label: provider.label, - icon: getProviderIcon(provider.id, "size-4"), - })), - }, - ]} - onValueChange={onProviderChange} + {(agentProviders.length > 0 || providersLoading) && ( + )} - {availableModels.length > 0 && ( - - - - - - {t("toolbar.model")} - - {availableModels.map((model) => ( - onModelChange?.(model.id)} - className="flex items-center justify-between" - > -
- - {model.displayName ?? model.name} - - {model.provider && ( - - {model.provider} - - )} -
- {model.id === currentModel && ( - - )} -
- ))} -
-
- )} - void; } -const CP_PAD = 12; -const CP_TOTAL_W = 340 + CP_PAD * 2; -const CP_TOGGLE_RIGHT = CP_PAD + 12; -const CP_TOGGLE_TOP = CP_PAD + 10; -const CP_FADE_S = 0.15; -const CP_REFLOW_MS = 200; - export function ChatView({ sessionId, agentName = "Goose", @@ -68,9 +58,6 @@ export function ChatView({ (s) => s.contextPanelOpenBySession[activeSessionId] ?? false, ); const setContextPanelOpen = useChatSessionStore((s) => s.setContextPanelOpen); - const shouldReduceMotion = useReducedMotion(); - const fadeTransition = { duration: shouldReduceMotion ? 0 : CP_FADE_S }; - const reflowDuration = shouldReduceMotion ? 0 : CP_REFLOW_MS; const { providers, @@ -85,6 +72,9 @@ export function ChatView({ const session = useChatSessionStore((s) => s.sessions.find((candidate) => candidate.id === activeSessionId), ); + const availableModels = useChatSessionStore( + (s) => s.modelsBySession[activeSessionId] ?? EMPTY_MODELS, + ); const projects = useProjectStore((s) => s.projects); const storedProject = useProjectStore((s) => session?.projectId @@ -190,10 +180,10 @@ export function ChatView({ if (providerId === selectedProvider) { return; } + const sessionStore = useChatSessionStore.getState(); + const cached = sessionStore.getCachedModels(providerId); + sessionStore.switchSessionProvider(activeSessionId, providerId, cached); setGlobalSelectedProvider(providerId); - useChatSessionStore - .getState() - .updateSession(activeSessionId, { providerId }); }, [activeSessionId, selectedProvider, setGlobalSelectedProvider], ); @@ -206,6 +196,40 @@ export function ChatView({ }, [activeSessionId], ); + const handleModelChange = useCallback( + (modelId: string) => { + if (!activeSessionId || modelId === session?.modelId) { + return; + } + + const previousModelId = session?.modelId; + const previousModelName = session?.modelName; + const models = useChatSessionStore + .getState() + .getSessionModels(activeSessionId); + const selected = models.find((m) => m.id === modelId); + + // Optimistic update + useChatSessionStore.getState().updateSession(activeSessionId, { + modelId, + modelName: selected?.displayName ?? selected?.name ?? modelId, + }); + + if (session?.draft) { + return; + } + + acpSetModel(activeSessionId, modelId).catch((error) => { + console.error("Failed to set model:", error); + // Rollback to previous model on failure + useChatSessionStore.getState().updateSession(activeSessionId, { + modelId: previousModelId, + modelName: previousModelName, + }); + }); + }, + [activeSessionId, session?.draft, session?.modelId, session?.modelName], + ); // When persona changes, update the provider to match persona's default const handlePersonaChange = useCallback( @@ -258,7 +282,6 @@ export function ChatView({ const personaInfo = selectedPersona ? { id: selectedPersona.id, name: selectedPersona.displayName } : undefined; - const { messages, chatState, @@ -283,6 +306,13 @@ export function ChatView({ } let cancelled = false; + const sessionStore = useChatSessionStore.getState(); + const currentSession = sessionStore.getSession(activeSessionId); + if (currentSession && currentSession.providerId !== selectedProvider) { + sessionStore.updateSession(activeSessionId, { + providerId: selectedProvider, + }); + } acpPrepareSession(activeSessionId, selectedProvider, { workingDir: effectiveWorkingDir, personaId: selectedPersonaId ?? undefined, @@ -435,6 +465,10 @@ export function ChatView({ providersLoading={providersLoading} selectedProvider={selectedProvider} onProviderChange={handleProviderChange} + currentModelId={session?.modelId ?? null} + currentModel={session?.modelName} + availableModels={availableModels} + onModelChange={handleModelChange} selectedProjectId={session?.projectId ?? null} availableProjects={availableProjects} onProjectChange={handleProjectChange} @@ -451,63 +485,13 @@ export function ChatView({ />
-
- - {isContextPanelOpen ? ( - - - - ) : null} - -
- -
- -
+
); diff --git a/ui/goose2/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx new file mode 100644 index 0000000000..99df58318f --- /dev/null +++ b/ui/goose2/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx @@ -0,0 +1,114 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { AgentModelPicker } from "../AgentModelPicker"; + +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} + +globalThis.ResizeObserver ??= + ResizeObserverStub as unknown as typeof ResizeObserver; + +const AGENTS = [ + { id: "goose", label: "Goose" }, + { id: "codex-acp", label: "Codex" }, +]; + +describe("AgentModelPicker", () => { + it("shows the selected agent and model in the trigger", () => { + render( + , + ); + + expect( + screen.getByRole("button", { name: /choose agent and model/i }), + ).toHaveTextContent("GPT-4o"); + }); + + it("calls onModelChange when a model is selected", async () => { + const user = userEvent.setup(); + const onModelChange = vi.fn(); + + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + + await user.click(screen.getByRole("button", { name: /OpenAI/i })); + await user.click(screen.getByRole("button", { name: "GPT-4o" })); + + expect(onModelChange).toHaveBeenCalledWith("gpt-4o"); + }); + + it("auto-expands the group containing the selected model", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + + expect( + screen.getByRole("button", { name: "Claude Sonnet 4" }), + ).toBeInTheDocument(); + }); + + it("shows only agent name when no model info is available", () => { + render( + , + ); + + const trigger = screen.getByRole("button", { + name: /choose agent and model/i, + }); + expect(trigger).toHaveTextContent("Goose"); + expect(trigger).not.toHaveTextContent("·"); + }); +}); diff --git a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx index 8e7942331c..0135ad5321 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx @@ -5,6 +5,14 @@ import { useState } from "react"; import { ChatInput } from "../ChatInput"; import type { Persona } from "@/shared/types/agents"; +vi.mock("@/features/providers/hooks/useAgentProviderStatus", () => ({ + useAgentProviderStatus: () => ({ + readyAgentIds: new Set(["goose", "claude-acp", "codex-acp"]), + loading: false, + refresh: vi.fn(), + }), +})); + const TEST_PERSONAS: Persona[] = [ { id: "builtin-solo", @@ -96,11 +104,15 @@ describe("ChatInput", () => { render( , ); - expect(screen.getByText("GPT-4o")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /choose agent and model/i }), + ).toHaveTextContent("GPT-4o"); }); it("shows default model name in model picker", () => { @@ -108,9 +120,12 @@ describe("ChatInput", () => { , ); - expect(screen.getByText("Claude Sonnet 4")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /choose agent and model/i }), + ).toHaveTextContent("Claude Sonnet 4"); }); it("shows default provider label", () => { @@ -122,12 +137,12 @@ describe("ChatInput", () => { />, ); const providerButton = screen.getByRole("button", { - name: /choose a provider/i, + name: /choose agent and model/i, }); expect(providerButton).toHaveTextContent("Goose"); }); - it("opens the provider selector menu", async () => { + it("opens the agent and model picker", async () => { const user = userEvent.setup(); render( @@ -135,18 +150,19 @@ describe("ChatInput", () => { onSend={vi.fn()} providers={[ { id: "goose", label: "Goose" }, - { id: "openai", label: "OpenAI" }, + { id: "claude-acp", label: "Claude Code" }, ]} selectedProvider="goose" />, ); await user.click( - screen.getByRole("button", { name: /choose a provider/i }), + screen.getByRole("button", { name: /choose agent and model/i }), ); - expect(screen.getByText("Choose a provider")).toBeInTheDocument(); - expect(screen.getByText("OpenAI")).toBeInTheDocument(); + expect(screen.getByText("Agent")).toBeInTheDocument(); + expect(screen.getByText("Model")).toBeInTheDocument(); + expect(screen.getByText("Claude Code")).toBeInTheDocument(); }); it("opens the project selector menu", async () => { diff --git a/ui/goose2/src/features/home/ui/HomeScreen.test.tsx b/ui/goose2/src/features/home/ui/HomeScreen.test.tsx index e235e46ed9..56e454cd87 100644 --- a/ui/goose2/src/features/home/ui/HomeScreen.test.tsx +++ b/ui/goose2/src/features/home/ui/HomeScreen.test.tsx @@ -9,15 +9,23 @@ const setSelectedProviderWithoutPersist = vi.fn(); vi.mock("@/shared/api/acp", () => ({ discoverAcpProviders: vi.fn().mockResolvedValue([ { id: "goose", label: "Goose" }, - { id: "openai", label: "OpenAI" }, + { id: "claude-acp", label: "Claude Code" }, ]), })); +vi.mock("@/features/providers/hooks/useAgentProviderStatus", () => ({ + useAgentProviderStatus: () => ({ + readyAgentIds: new Set(["goose", "claude-acp", "codex-acp"]), + loading: false, + refresh: vi.fn(), + }), +})); + vi.mock("@/features/agents/hooks/useProviderSelection", () => ({ useProviderSelection: () => ({ providers: [ { id: "goose", label: "Goose" }, - { id: "openai", label: "OpenAI" }, + { id: "claude-acp", label: "Claude Code" }, ], providersLoading: false, selectedProvider: "goose", @@ -117,7 +125,7 @@ describe("HomeScreen", () => { it("renders the provider and project controls on the home screen", () => { render(); expect( - screen.getByRole("button", { name: /choose a provider/i }), + screen.getByRole("button", { name: /choose agent and model/i }), ).toBeInTheDocument(); expect( screen.getByRole("button", { name: /select project/i }), diff --git a/ui/goose2/src/features/providers/hooks/useAgentProviderStatus.ts b/ui/goose2/src/features/providers/hooks/useAgentProviderStatus.ts new file mode 100644 index 0000000000..4d064b729b --- /dev/null +++ b/ui/goose2/src/features/providers/hooks/useAgentProviderStatus.ts @@ -0,0 +1,116 @@ +import { useState, useEffect, useCallback } from "react"; +import { + checkAgentInstalled, + checkAgentAuth, +} from "@/features/providers/api/agentSetup"; +import { + getAgentProviders, + getCatalogEntry, +} from "@/features/providers/providerCatalog"; + +interface UseAgentProviderStatusReturn { + readyAgentIds: Set; + loading: boolean; + refresh: () => Promise; +} + +async function checkAgentProviderReady(providerId: string): Promise { + const provider = getCatalogEntry(providerId); + if (!provider || provider.category !== "agent") { + return false; + } + + if (provider.setupMethod === "none") { + return true; + } + + if (!provider.binaryName) { + return false; + } + + try { + const installed = await checkAgentInstalled(provider.id); + if (!installed) { + return false; + } + + if (provider.authStatusCommand) { + return checkAgentAuth(provider.id); + } + + if (provider.authCommand) { + return ( + localStorage.getItem(`agent-provider-auth:${provider.id}`) === "true" + ); + } + + return true; + } catch { + return false; + } +} + +const INITIAL_READY_AGENTS = new Set(["goose"]); + +export function useAgentProviderStatus(): UseAgentProviderStatusReturn { + const [readyAgentIds, setReadyAgentIds] = + useState>(INITIAL_READY_AGENTS); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + const agentIds = getAgentProviders().map((provider) => provider.id); + let remaining = agentIds.length; + + for (const agentId of agentIds) { + checkAgentProviderReady(agentId) + .then((isReady) => { + if (!cancelled && isReady) { + setReadyAgentIds((current) => { + if (current.has(agentId)) { + return current; + } + + const next = new Set(current); + next.add(agentId); + return next; + }); + } + }) + .finally(() => { + remaining -= 1; + if (!cancelled && remaining === 0) { + setLoading(false); + } + }); + } + + return () => { + cancelled = true; + }; + }, []); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const readiness = await Promise.all( + getAgentProviders().map(async (provider) => ({ + id: provider.id, + isReady: await checkAgentProviderReady(provider.id), + })), + ); + const readyIds = readiness + .filter((provider) => provider.isReady) + .map((provider) => provider.id); + setReadyAgentIds(new Set(["goose", ...readyIds])); + } finally { + setLoading(false); + } + }, []); + + return { + readyAgentIds, + loading, + refresh, + }; +} diff --git a/ui/goose2/src/features/providers/providerCatalog.test.ts b/ui/goose2/src/features/providers/providerCatalog.test.ts new file mode 100644 index 0000000000..7a1f0ddf19 --- /dev/null +++ b/ui/goose2/src/features/providers/providerCatalog.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { resolveAgentProviderCatalogId } from "./providerCatalog"; + +describe("resolveAgentProviderCatalogId", () => { + it("matches direct catalog ids", () => { + expect(resolveAgentProviderCatalogId("cursor-agent", "Cursor Agent")).toBe( + "cursor-agent", + ); + }); + + it("matches common agent aliases", () => { + expect(resolveAgentProviderCatalogId("codex-cli", "Codex CLI")).toBe( + "codex-acp", + ); + expect(resolveAgentProviderCatalogId("claude-code", "Claude Code")).toBe( + "claude-acp", + ); + }); + + it("does not treat model providers as agents", () => { + expect( + resolveAgentProviderCatalogId("databricks", "Databricks"), + ).toBeNull(); + }); + + it("matches fuzzy agent labels with extra suffixes", () => { + expect( + resolveAgentProviderCatalogId("custom-id", "Claude Code (ACP)"), + ).toBe("claude-acp"); + expect(resolveAgentProviderCatalogId("custom-id", "Codex CLI (ACP)")).toBe( + "codex-acp", + ); + expect( + resolveAgentProviderCatalogId("custom-id", "Cursor Agent Stable"), + ).toBe("cursor-agent"); + }); +}); diff --git a/ui/goose2/src/features/providers/providerCatalog.ts b/ui/goose2/src/features/providers/providerCatalog.ts index dac83352d6..a6d86d78dc 100644 --- a/ui/goose2/src/features/providers/providerCatalog.ts +++ b/ui/goose2/src/features/providers/providerCatalog.ts @@ -1,4 +1,9 @@ import type { ProviderCatalogEntry } from "@/shared/types/providers"; +import { + AGENT_PROVIDER_ALIAS_MAP, + AGENT_PROVIDER_FUZZY_MATCHERS, + normalizeProviderKey, +} from "./providerCatalogAliases"; export const PROVIDER_CATALOG: ProviderCatalogEntry[] = [ // ── Agent providers ────────────────────────────────────────────── @@ -431,3 +436,55 @@ export function getAgentProviders(): ProviderCatalogEntry[] { export function getModelProviders(): ProviderCatalogEntry[] { return PROVIDER_CATALOG.filter((p) => p.category === "model"); } + +export function resolveAgentProviderCatalogIdStrict( + providerId: string, +): string | null { + const directMatch = getAgentProviders().find( + (provider) => provider.id === providerId, + ); + if (directMatch) { + return directMatch.id; + } + + const normalized = normalizeProviderKey(providerId); + const aliasMatch = AGENT_PROVIDER_ALIAS_MAP[normalized]; + if (aliasMatch) { + return aliasMatch; + } + + return null; +} + +export function resolveAgentProviderCatalogId( + providerId: string, + label?: string, +): string | null { + const directMatch = getAgentProviders().find( + (provider) => provider.id === providerId, + ); + if (directMatch) { + return directMatch.id; + } + + const normalizedCandidates = [providerId, label ?? ""] + .map((value) => normalizeProviderKey(value)) + .filter(Boolean); + + for (const candidate of normalizedCandidates) { + const aliasMatch = AGENT_PROVIDER_ALIAS_MAP[candidate]; + if (aliasMatch) { + return aliasMatch; + } + } + + for (const candidate of normalizedCandidates) { + for (const [needle, catalogId] of AGENT_PROVIDER_FUZZY_MATCHERS) { + if (candidate.includes(needle)) { + return catalogId; + } + } + } + + return null; +} diff --git a/ui/goose2/src/features/providers/providerCatalogAliases.ts b/ui/goose2/src/features/providers/providerCatalogAliases.ts new file mode 100644 index 0000000000..91b19e5b67 --- /dev/null +++ b/ui/goose2/src/features/providers/providerCatalogAliases.ts @@ -0,0 +1,35 @@ +export function normalizeProviderKey(value: string): string { + return value + .toLowerCase() + .split(/[-_\s]+/) + .filter(Boolean) + .join("_"); +} + +export const AGENT_PROVIDER_ALIAS_MAP: Record = { + goose: "goose", + claude_acp: "claude-acp", + claude_code: "claude-acp", + claude: "claude-acp", + codex_acp: "codex-acp", + codex_cli: "codex-acp", + codex: "codex-acp", + copilot_acp: "copilot-acp", + github_copilot: "copilot-acp", + github_copilot_cli: "copilot-acp", + cursor_agent: "cursor-agent", + cursor: "cursor-agent", + amp_acp: "amp-acp", + amp: "amp-acp", + pi_acp: "pi-acp", + pi: "pi-acp", +}; + +export const AGENT_PROVIDER_FUZZY_MATCHERS: Array<[string, string]> = [ + ["goose", "goose"], + ["claude", "claude-acp"], + ["codex", "codex-acp"], + ["cursor", "cursor-agent"], + ["copilot", "copilot-acp"], + ["amp", "amp-acp"], +]; diff --git a/ui/goose2/src/features/settings/ui/SettingsModal.tsx b/ui/goose2/src/features/settings/ui/SettingsModal.tsx index d4cabd7d85..0a9f447ab1 100644 --- a/ui/goose2/src/features/settings/ui/SettingsModal.tsx +++ b/ui/goose2/src/features/settings/ui/SettingsModal.tsx @@ -55,16 +55,20 @@ const NAV_ITEMS = [ { id: "about", labelKey: "nav.about", icon: Info }, ] as const; -type SectionId = (typeof NAV_ITEMS)[number]["id"]; +export type SectionId = (typeof NAV_ITEMS)[number]["id"]; interface SettingsModalProps { onClose: () => void; + initialSection?: SectionId; } -export function SettingsModal({ onClose }: SettingsModalProps) { +export function SettingsModal({ + onClose, + initialSection = "appearance", +}: SettingsModalProps) { const { t } = useTranslation(["settings", "common"]); const { preference, setLocalePreference, systemLocaleLabel } = useLocale(); - const [activeSection, setActiveSection] = useState("appearance"); + const [activeSection, setActiveSection] = useState(initialSection); const [isLoaded, setIsLoaded] = useState(false); const [isTransitioning, setIsTransitioning] = useState(false); const [archivedProjects, setArchivedProjects] = useState([]); diff --git a/ui/goose2/src/shared/api/acp.ts b/ui/goose2/src/shared/api/acp.ts index 8935285222..c55ae8fa3b 100644 --- a/ui/goose2/src/shared/api/acp.ts +++ b/ui/goose2/src/shared/api/acp.ts @@ -59,6 +59,16 @@ export async function acpPrepareSession( }); } +export async function acpSetModel( + sessionId: string, + modelId: string, +): Promise { + return invoke("acp_set_model", { + sessionId, + modelId, + }); +} + /** Session info returned by the goose binary's list_sessions. */ export interface AcpSessionInfo { sessionId: string; diff --git a/ui/goose2/src/shared/i18n/locales/en/chat.json b/ui/goose2/src/shared/i18n/locales/en/chat.json index e8fc061b77..7529bdf04a 100644 --- a/ui/goose2/src/shared/i18n/locales/en/chat.json +++ b/ui/goose2/src/shared/i18n/locales/en/chat.json @@ -92,6 +92,8 @@ "yesterday": "Yesterday" }, "toolbar": { + "agent": "Agent", + "chooseAgentModel": "Choose agent and model", "chooseProject": "Choose a project", "chooseProvider": "Choose a provider", "contextUsage": "Context usage", diff --git a/ui/goose2/src/shared/i18n/locales/es/chat.json b/ui/goose2/src/shared/i18n/locales/es/chat.json index f2dd3d97ae..b644ba23b5 100644 --- a/ui/goose2/src/shared/i18n/locales/es/chat.json +++ b/ui/goose2/src/shared/i18n/locales/es/chat.json @@ -92,6 +92,8 @@ "yesterday": "Ayer" }, "toolbar": { + "agent": "Agente", + "chooseAgentModel": "Elegir agente y modelo", "chooseProject": "Elegir un proyecto", "chooseProvider": "Elegir un proveedor", "contextUsage": "Uso del contexto", diff --git a/ui/goose2/src/shared/types/chat.ts b/ui/goose2/src/shared/types/chat.ts index 6c9d246f73..4490cf902e 100644 --- a/ui/goose2/src/shared/types/chat.ts +++ b/ui/goose2/src/shared/types/chat.ts @@ -55,6 +55,7 @@ export interface Session { projectId?: string | null; providerId?: string; personaId?: string; + modelId?: string; modelName?: string; createdAt: string; updatedAt: string;