From c6177440add037e68019e38e1097742aff40ea68 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:28:39 -0700 Subject: [PATCH] add agent model picker and session model sync (#137) * Enhance chat session model management and UI integration - Introduced model selection capabilities in the chat input and toolbar, allowing users to choose from available models based on the selected provider. - Updated the chat session store to manage models per session, including caching and retrieval of available models. - Implemented backend support for setting the model in active sessions, ensuring seamless integration with the ACP service. - Enhanced the SettingsModal to support initial section selection for improved user experience. - Added tests to validate model state management and UI interactions. This commit builds upon the provider setup infrastructure and shared UI primitives, further refining the user experience in managing chat sessions and models. * Enhance debugging and performance logging for ACP provider interactions - Added detailed performance logging for various ACP provider functions, including `discoverAcpProviders`, `checkAgentInstalled`, and `checkAgentAuth`, to track execution time and results. - Improved session management in the chat by logging state changes and model updates, ensuring better traceability during provider interactions. - Updated the chat session store to include a new method for switching session providers, enhancing model caching and retrieval. - Refined UI components to improve layout and responsiveness, ensuring a better user experience when selecting models and agents. These changes aim to provide better insights into the performance of ACP interactions and improve the overall user experience in managing chat sessions. * Refactor ACP provider interactions and improve error handling - Removed excessive logging from various ACP provider functions, including `discoverAcpProviders`, `checkAgentInstalled`, and `checkAgentAuth`, to streamline performance. - Enhanced error handling in the chat session management by providing clearer error messages when loading ACP providers and setting models. - Updated the UI components to improve layout and responsiveness, ensuring a better user experience when selecting models and agents. - Refactored the `AgentModelPicker` and `ChatView` components for better readability and maintainability. These changes aim to simplify the codebase while improving the clarity of error messages and enhancing the overall user experience in managing chat sessions and models. * Refactor AgentModelPicker UI and update tests for model display - Simplified the display logic in the `AgentModelPicker` component to use nullish coalescing for better readability. - Updated test assertions in `AgentModelPicker.test.tsx` and `ChatInput.test.tsx` to reflect changes in the model display format, ensuring consistency across the UI. - Removed unnecessary concatenation in the button text, enhancing clarity for users selecting agents and models. These changes aim to improve the user interface and maintain accurate test coverage for model selection functionality. * Refactor AppShell and ChatView components for improved structure and readability - Replaced the content rendering logic in `AppShell` with a new `AppShellContent` component to streamline the layout and enhance maintainability. - Simplified the `ChatView` component by integrating a new `ChatContextPanel`, removing unnecessary complexity and improving the overall user interface. - Updated the `useAcpStream` hook to include additional debug logging for better traceability of provider events. - Cleaned up the `providerCatalog` by removing redundant code and organizing imports for clarity. These changes aim to enhance the code structure, improve user experience, and facilitate easier future modifications. * Fix post-rebase CI issues Restore the missing ACP manager imports from the rebase merge and remove small lint/file-size regressions so the branch passes the local check gate again. * Fix draft and replay model selection handling Delay model updates until draft chats have prepared ACP sessions, and keep providerless replay model-state events so reopened chats show the correct model picker state. * Refactor session management for improved readability and consistency - Reformatted the `bind_session` and `emit_model_state_from_options` calls in `prepare_session_inner` and `set_model_inner` functions for better code clarity. - Enhanced the structure of session handling logic to maintain consistency across the codebase. These changes aim to improve the maintainability of the session management code while ensuring consistent formatting throughout the file. * Split ACP session ops tests into a submodule Move the session_ops unit tests out of the production Rust file so the desktop file-size check passes without adding a broader exception. --- ui/goose2/src-tauri/src/commands/acp.rs | 10 + ui/goose2/src-tauri/src/lib.rs | 1 + .../src-tauri/src/services/acp/manager.rs | 183 ++-------- .../services/acp/manager/command_dispatch.rs | 22 +- .../src/services/acp/manager/dispatcher.rs | 64 +++- .../src/services/acp/manager/session_ops.rs | 227 ++++++------ .../acp/manager/session_ops/prompt_ops.rs | 103 ++++++ .../services/acp/manager/session_ops/tests.rs | 36 ++ .../src/services/acp/manager/thread.rs | 169 +++++++++ ui/goose2/src-tauri/src/services/acp/mod.rs | 1 + .../src-tauri/src/services/acp/payloads.rs | 21 ++ .../src-tauri/src/services/acp/writer.rs | 11 +- ui/goose2/src/app/AppShell.tsx | 91 +++-- ui/goose2/src/app/ui/AppShellContent.tsx | 89 +++++ .../chat/hooks/__tests__/useAcpStream.test.ts | 70 ++++ .../chat/hooks/__tests__/useChat.test.ts | 56 +++ .../src/features/chat/hooks/useAcpStream.ts | 51 +-- ui/goose2/src/features/chat/hooks/useChat.ts | 16 +- .../stores/__tests__/chatSessionStore.test.ts | 49 +++ .../features/chat/stores/chatSessionStore.ts | 108 +++++- ui/goose2/src/features/chat/types.ts | 6 + .../src/features/chat/ui/AgentModelPicker.tsx | 328 ++++++++++++++++++ .../src/features/chat/ui/ChatContextPanel.tsx | 98 ++++++ ui/goose2/src/features/chat/ui/ChatInput.tsx | 17 +- .../src/features/chat/ui/ChatInputToolbar.tsx | 150 +++----- ui/goose2/src/features/chat/ui/ChatView.tsx | 142 ++++---- .../ui/__tests__/AgentModelPicker.test.tsx | 114 ++++++ .../chat/ui/__tests__/ChatInput.test.tsx | 32 +- .../src/features/home/ui/HomeScreen.test.tsx | 14 +- .../providers/hooks/useAgentProviderStatus.ts | 116 +++++++ .../providers/providerCatalog.test.ts | 37 ++ .../src/features/providers/providerCatalog.ts | 57 +++ .../providers/providerCatalogAliases.ts | 35 ++ .../features/settings/ui/SettingsModal.tsx | 10 +- ui/goose2/src/shared/api/acp.ts | 10 + .../src/shared/i18n/locales/en/chat.json | 2 + .../src/shared/i18n/locales/es/chat.json | 2 + ui/goose2/src/shared/types/chat.ts | 1 + 38 files changed, 1980 insertions(+), 569 deletions(-) create mode 100644 ui/goose2/src-tauri/src/services/acp/manager/session_ops/prompt_ops.rs create mode 100644 ui/goose2/src-tauri/src/services/acp/manager/session_ops/tests.rs create mode 100644 ui/goose2/src-tauri/src/services/acp/manager/thread.rs create mode 100644 ui/goose2/src/app/ui/AppShellContent.tsx create mode 100644 ui/goose2/src/features/chat/types.ts create mode 100644 ui/goose2/src/features/chat/ui/AgentModelPicker.tsx create mode 100644 ui/goose2/src/features/chat/ui/ChatContextPanel.tsx create mode 100644 ui/goose2/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx create mode 100644 ui/goose2/src/features/providers/hooks/useAgentProviderStatus.ts create mode 100644 ui/goose2/src/features/providers/providerCatalog.test.ts create mode 100644 ui/goose2/src/features/providers/providerCatalogAliases.ts 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;