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.
This commit is contained in:
morgmart
2026-04-09 09:28:39 -07:00
committed by GitHub
parent 45b0638164
commit c6177440ad
38 changed files with 1980 additions and 569 deletions
+10
View File
@@ -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<Vec<AcpSessionInfo>, String> {
+1
View File
@@ -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,
+27 -156
View File
@@ -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<Result<AcpSessionInfo, String>>,
},
SetModel {
local_session_id: String,
model_id: String,
response: oneshot::Sender<Result<(), String>>,
},
}
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<String, String> {
let (response_tx, response_rx) = oneshot::channel();
@@ -290,148 +304,5 @@ fn run_manager_thread(
command_rx: mpsc::UnboundedReceiver<ManagerCommand>,
ready_tx: oneshot::Sender<Result<(), String>>,
) {
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::<String, SessionRoute>::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);
}
@@ -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);
});
}
}
}
}
@@ -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<ModelOption> {
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<String>,
pub(super) writer: Option<Arc<dyn MessageWriter>>,
pub(super) canceled: bool,
/// Tracks the current assistant message ID during replay (no writer).
pub(super) replay_message_id: Option<String>,
}
@@ -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<dyn MessageWriter>,
) {
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(());
}
_ => {}
@@ -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<Mutex<ManagerState>>, composite_key: &str) -> bool {
let mut guard = state.lock().await;
guard.take_cancel_requested(composite_key)
}
async fn clear_cancel_requested(state: &Arc<Mutex<ManagerState>>, 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<ClientSideConnection>,
dispatcher: &Arc<SessionEventDispatcher>,
state: &Arc<Mutex<ManagerState>>,
composite_key: String,
local_session_id: String,
provider_id: String,
working_dir: PathBuf,
existing_agent_session_id: Option<String>,
writer: Arc<dyn MessageWriter>,
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<ClientSideConnection>,
@@ -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<ClientSideConnection>,
dispatcher: &Arc<SessionEventDispatcher>,
state: &Arc<Mutex<ManagerState>>,
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::<Vec<_>>()
};
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(())
}
@@ -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<Mutex<ManagerState>>, composite_key: &str) -> bool {
let mut guard = state.lock().await;
guard.take_cancel_requested(composite_key)
}
async fn clear_cancel_requested(state: &Arc<Mutex<ManagerState>>, 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<ClientSideConnection>,
dispatcher: &Arc<SessionEventDispatcher>,
state: &Arc<Mutex<ManagerState>>,
composite_key: String,
local_session_id: String,
provider_id: String,
working_dir: PathBuf,
existing_agent_session_id: Option<String>,
writer: Arc<dyn MessageWriter>,
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
}
@@ -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));
}
@@ -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<ManagerCommand>,
ready_tx: oneshot::Sender<Result<(), String>>,
) {
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::<String, SessionRoute>::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;
});
}
@@ -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(),
));
@@ -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<String>,
}
#[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<String>,
pub current_model_id: String,
pub current_model_name: Option<String>,
pub available_models: Vec<ModelOption>,
}
pub(crate) fn model_options_from_state(state: &SessionModelState) -> Vec<ModelOption> {
state
.available_models
.iter()
.map(|model| ModelOption {
id: model.model_id.to_string(),
name: model.name.clone(),
})
.collect()
}
@@ -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<String>,
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<String>,
persona_id: Option<String>,
persona_name: Option<String>,
) -> 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,
},
);
}
+39 -52
View File
@@ -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<SectionId>("appearance");
const [createProjectOpen, setCreateProjectOpen] = useState(false);
const [createProjectInitialWorkingDir, setCreateProjectInitialWorkingDir] =
useState<string | null>(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 <SkillsView />;
case "agents":
return <AgentsView />;
case "projects":
return <ProjectsView onStartChat={handleStartChatFromProject} />;
case "session-history":
return (
<SessionHistoryView
onSelectSession={handleSelectSession}
onRenameChat={handleRenameChat}
onArchiveChat={handleArchiveChat}
/>
);
case "chat":
case "home":
return activeSession ? (
<ChatView
key={activeSession.id}
sessionId={activeSession.id}
initialProvider={homeSelectedProvider}
initialPersonaId={activeSessionPersonaId ?? homeSelectedPersonaId}
initialMessage={pendingInitialMessage}
initialImages={pendingInitialImages}
onCreateProject={openCreateProjectDialog}
onInitialMessageConsumed={() => {
setPendingInitialMessage(undefined);
setPendingInitialImages(undefined);
setHomeSelectedProvider(undefined);
setHomeSelectedPersonaId(undefined);
}}
/>
) : (
<HomeScreen
onStartChat={handleHomeStartChat}
onCreateProject={openCreateProjectDialog}
/>
);
}
};
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 (
<div className="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground">
<TopBar onSettingsClick={() => setSettingsOpen(true)} />
<TopBar onSettingsClick={() => openSettings()} />
<div className="flex flex-1 min-h-0 overflow-hidden">
<div
@@ -546,7 +511,24 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
</div>
<main className="min-h-0 min-w-0 flex-1">
{children ?? renderContent()}
{children ?? (
<AppShellContent
activeView={activeView}
activeSession={activeSession}
activeSessionPersonaId={activeSessionPersonaId}
homeSelectedProvider={homeSelectedProvider}
homeSelectedPersonaId={homeSelectedPersonaId}
pendingInitialMessage={pendingInitialMessage}
pendingInitialImages={pendingInitialImages}
onArchiveChat={handleArchiveChat}
onCreateProject={openCreateProjectDialog}
onHomeStartChat={handleHomeStartChat}
onInitialMessageConsumed={handleInitialMessageConsumed}
onRenameChat={handleRenameChat}
onSelectSession={handleSelectSession}
onStartChatFromProject={handleStartChatFromProject}
/>
)}
</main>
</div>
@@ -562,7 +544,12 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
/>
</div>
{settingsOpen && <SettingsModal onClose={() => setSettingsOpen(false)} />}
{settingsOpen && (
<SettingsModal
initialSection={settingsInitialSection}
onClose={() => setSettingsOpen(false)}
/>
)}
<CreateProjectDialog
isOpen={createProjectOpen}
+89
View File
@@ -0,0 +1,89 @@
import { HomeScreen } from "@/features/home/ui/HomeScreen";
import { ChatView } from "@/features/chat/ui/ChatView";
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 type { ChatSession } from "@/features/chat/stores/chatSessionStore";
import type { PastedImage } from "@/shared/types/messages";
import type { ProjectInfo } from "@/features/projects/api/projects";
import type { AppView } from "../AppShell";
interface AppShellContentProps {
activeView: AppView;
activeSession?: ChatSession;
activeSessionPersonaId?: string;
homeSelectedProvider?: string;
homeSelectedPersonaId?: string;
pendingInitialMessage?: string;
pendingInitialImages?: PastedImage[];
onArchiveChat: (sessionId: string) => Promise<void>;
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 <SkillsView />;
case "agents":
return <AgentsView />;
case "projects":
return <ProjectsView onStartChat={onStartChatFromProject} />;
case "session-history":
return (
<SessionHistoryView
onSelectSession={onSelectSession}
onRenameChat={onRenameChat}
onArchiveChat={onArchiveChat}
/>
);
case "chat":
case "home":
return activeSession ? (
<ChatView
key={activeSession.id}
sessionId={activeSession.id}
initialProvider={homeSelectedProvider}
initialPersonaId={activeSessionPersonaId ?? homeSelectedPersonaId}
initialMessage={pendingInitialMessage}
initialImages={pendingInitialImages}
onCreateProject={onCreateProject}
onInitialMessageConsumed={onInitialMessageConsumed}
/>
) : (
<HomeScreen
onStartChat={onHomeStartChat}
onCreateProject={onCreateProject}
/>
);
}
}
@@ -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");
@@ -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"),
@@ -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<UnlistenFn>[] = [];
// 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<AcpModelStatePayload>("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,
});
}),
);
+15 -1
View File
@@ -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");
@@ -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();
@@ -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<string, boolean>;
modelsBySession: Record<string, ModelOption[]>;
modelCacheByProvider: Record<string, ModelOption[]>;
}
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<string, ModelOption[]> {
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<string, ModelOption[]>)
: {};
} catch {
return {};
}
}
function persistModelCache(cache: Record<string, ModelOption[]>): 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<ChatSessionStore>((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<ChatSessionStore>((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<ChatSessionStore>((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<ChatSessionStore>((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<ChatSessionStore>((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,
}));
+6
View File
@@ -0,0 +1,6 @@
export interface ModelOption {
id: string;
name: string;
displayName?: string;
provider?: string;
}
@@ -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<string, ModelOption[]>();
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 (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors",
"hover:bg-muted focus-visible:bg-muted focus:outline-none",
"disabled:pointer-events-none disabled:opacity-50",
selected && "bg-muted/60",
className,
)}
>
{children}
</button>
);
}
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<Set<string>>(new Set());
const selectedAgentLabel =
agents.find((agent) => agent.id === selectedAgentId)?.label ??
formatProviderLabel(selectedAgentId);
const groupedModels = useMemo<ModelGroup[]>(
() => 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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="toolbar"
size="sm"
aria-label={t("toolbar.chooseAgentModel")}
disabled={loading}
leftIcon={getProviderIcon(selectedAgentId, "size-3.5")}
rightIcon={<IconChevronDown className="opacity-50" />}
className="min-w-0"
>
<span className={cn("truncate", isCompact ? "max-w-32" : "max-w-56")}>
{loading
? t("toolbar.loading")
: (triggerModelLabel ?? selectedAgentLabel)}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-auto max-h-[min(24rem,50vh)] p-0"
>
<div className="flex max-h-[inherit]">
<div className="flex shrink-0 flex-col p-1">
<div className="shrink-0 px-2 py-1.5 text-sm font-semibold">
{t("toolbar.agent")}
</div>
<ScrollArea className="min-h-0 flex-1">
<div>
{agents.map((agent) => {
const isSelected = agent.id === selectedAgentId;
return (
<PickerItem
key={agent.id}
onClick={() => handleAgentSelect(agent.id)}
selected={isSelected}
>
<span className="shrink-0">
{getProviderIcon(agent.id, "size-4")}
</span>
<span className="min-w-0 flex-1 truncate">
{agent.label}
</span>
{isSelected ? (
<IconCheck className="size-4 shrink-0 text-muted-foreground" />
) : null}
</PickerItem>
);
})}
</div>
</ScrollArea>
</div>
<Separator orientation="vertical" className="self-stretch" />
<div className="flex shrink-0 flex-col p-1">
<div className="shrink-0 px-2 py-1.5 text-sm font-semibold">
{t("toolbar.model")}
</div>
{groupedModels.length > 0 ? (
<ScrollArea className="min-h-0 flex-1">
<div className="pr-3">
{groupedModels.map((group) => {
const expanded = isGroupExpanded(group);
return (
<div key={group.provider}>
<button
type="button"
onClick={() => toggleGroup(group.provider)}
className={cn(
"flex w-full items-center gap-1.5 rounded-sm px-2 py-1.5 text-left text-sm font-medium transition-colors",
"hover:bg-muted focus:bg-muted focus:outline-none",
)}
>
<IconChevronRight
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform",
expanded && "rotate-90",
)}
/>
<span className="flex-1">{group.provider}</span>
<span className="text-xs text-muted-foreground">
{group.models.length}
</span>
</button>
{expanded ? (
<div className="pb-1">
{group.models.map((model) => {
const modelName = getModelDisplayName(model);
return (
<PickerItem
key={model.id}
onClick={() => handleModelSelect(model.id)}
selected={model.id === currentModelId}
className="justify-between pl-6"
>
<div className="min-w-0 flex-1 truncate">
{modelName}
</div>
{model.id === currentModelId ? (
<IconCheck className="size-4 shrink-0 text-muted-foreground" />
) : null}
</PickerItem>
);
})}
</div>
) : group.hasSelectedModel ? (
<div className="pb-1">
{group.models
.filter((m) => m.id === currentModelId)
.map((model) => (
<PickerItem
key={model.id}
onClick={() => handleModelSelect(model.id)}
selected
className="justify-between pl-6"
>
<div className="min-w-0 flex-1 truncate">
{getModelDisplayName(model)}
</div>
<IconCheck className="size-4 shrink-0 text-muted-foreground" />
</PickerItem>
))}
</div>
) : null}
</div>
);
})}
</div>
</ScrollArea>
) : (
<div className="px-2 py-2">
<div className="text-sm text-muted-foreground">
{currentModelName ? currentModelName : t("toolbar.loading")}
</div>
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
}
@@ -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 (
<>
<div
className="shrink-0 overflow-hidden"
style={{
width: isOpen ? CP_TOTAL_W : 0,
transition: `width ${reflowDuration}ms ease`,
}}
>
<AnimatePresence initial={false}>
{isOpen ? (
<motion.div
key="context-panel"
className="flex h-full"
style={{
width: CP_TOTAL_W,
padding: CP_PAD,
}}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={fadeTransition}
>
<aside className="flex min-w-0 flex-1 overflow-hidden rounded-xl border border-border bg-background">
<ContextPanel
projectName={project?.name}
projectColor={project?.color}
projectWorkingDirs={project?.workingDirs ?? []}
/>
</aside>
</motion.div>
) : null}
</AnimatePresence>
</div>
<div
className="absolute z-20"
style={{
right: CP_TOGGLE_RIGHT,
top: CP_TOGGLE_TOP,
}}
>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setOpen(activeSessionId, !isOpen)}
aria-label={label}
title={label}
>
{isOpen ? (
<IconLayoutSidebarRightFilled className="size-4" />
) : (
<IconLayoutSidebarRight className="size-4" />
)}
</Button>
</div>
</>
);
}
+8 -9
View File
@@ -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}
@@ -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<string>();
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({
<div className="flex items-center justify-between gap-2">
{/* Left side: pickers */}
<div className="flex items-center gap-0.5">
{(availableProviderItems.length > 0 || providersLoading) && (
<ChatInputSelector
ariaLabel={t("toolbar.chooseProvider")}
value={selectedProvider}
triggerLabel={
providersLoading ? t("toolbar.loading") : providerLabel
}
icon={getProviderIcon(selectedProvider, "size-3.5")}
triggerVariant="toolbar"
triggerSize="sm"
menuLabel={t("toolbar.chooseProvider")}
disabled={providersLoading}
sections={[
{
items: availableProviderItems.map((provider) => ({
value: provider.id,
label: provider.label,
icon: getProviderIcon(provider.id, "size-4"),
})),
},
]}
onValueChange={onProviderChange}
{(agentProviders.length > 0 || providersLoading) && (
<AgentModelPicker
agents={agentProviders}
selectedAgentId={selectedProvider}
onAgentChange={onProviderChange}
currentModelId={currentModelId}
currentModelName={currentModel ?? null}
availableModels={availableModels}
onModelChange={onModelChange}
loading={providersLoading}
isCompact={isCompact}
/>
)}
{availableModels.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
rightIcon={<ChevronDown className="opacity-50" />}
className="gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label={t("toolbar.selectModel")}
>
{!isCompact && <span>{currentModel}</span>}
{isCompact && (
<span className="max-w-[60px] truncate">{currentModel}</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>{t("toolbar.model")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{availableModels.map((model) => (
<DropdownMenuItem
key={model.id}
onSelect={() => onModelChange?.(model.id)}
className="flex items-center justify-between"
>
<div className="flex flex-col gap-0.5">
<span className="text-sm">
{model.displayName ?? model.name}
</span>
{model.provider && (
<span className="text-xs text-muted-foreground">
{model.provider}
</span>
)}
</div>
{model.id === currentModel && (
<Check className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<ChatInputSelector
ariaLabel={t("toolbar.selectProject")}
value={selectedProjectId ?? NO_PROJECT_VALUE}
+63 -79
View File
@@ -1,16 +1,10 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
IconLayoutSidebarRight,
IconLayoutSidebarRightFilled,
} from "@tabler/icons-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { MessageTimeline } from "./MessageTimeline";
import { ChatInput } from "./ChatInput";
import type { PastedImage } from "@/shared/types/messages";
import { LoadingGoose } from "./LoadingGoose";
import { ChatLoadingSkeleton } from "./ChatLoadingSkeleton";
import { ContextPanel } from "./ContextPanel";
import { useChat } from "../hooks/useChat";
import { useMessageQueue } from "../hooks/useMessageQueue";
import { useChatStore } from "../stores/chatStore";
@@ -19,7 +13,7 @@ import { useProviderSelection } from "@/features/agents/hooks/useProviderSelecti
import { useChatSessionStore } from "../stores/chatSessionStore";
import { getProject, type ProjectInfo } from "@/features/projects/api/projects";
import { useProjectStore } from "@/features/projects/stores/projectStore";
import { acpPrepareSession } from "@/shared/api/acp";
import { acpPrepareSession, acpSetModel } from "@/shared/api/acp";
import {
buildProjectSystemPrompt,
composeSystemPrompt,
@@ -27,8 +21,11 @@ import {
} from "@/features/projects/lib/chatProjectContext";
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
import { getHomeDir } from "@/shared/api/system";
import { Button } from "@/shared/ui/button";
import { ArtifactPolicyProvider } from "../hooks/ArtifactPolicyContext";
import type { ModelOption } from "../types";
import { ChatContextPanel } from "./ChatContextPanel";
const EMPTY_MODELS: ModelOption[] = [];
interface ChatViewProps {
sessionId: string;
@@ -44,13 +41,6 @@ interface ChatViewProps {
}) => 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({
/>
</div>
<div
className="shrink-0 overflow-hidden"
style={{
width: isContextPanelOpen ? CP_TOTAL_W : 0,
transition: `width ${reflowDuration}ms ease`,
}}
>
<AnimatePresence initial={false}>
{isContextPanelOpen ? (
<motion.div
key="context-panel"
className="flex h-full"
style={{
width: CP_TOTAL_W,
padding: CP_PAD,
}}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={fadeTransition}
>
<aside className="flex min-w-0 flex-1 overflow-hidden rounded-xl border border-border bg-background">
<ContextPanel
projectName={project?.name}
projectColor={project?.color}
projectWorkingDirs={project?.workingDirs ?? []}
/>
</aside>
</motion.div>
) : null}
</AnimatePresence>
</div>
<div
className="absolute z-20"
style={{
right: CP_TOGGLE_RIGHT,
top: CP_TOGGLE_TOP,
}}
>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() =>
setContextPanelOpen(activeSessionId, !isContextPanelOpen)
}
aria-label={contextPanelLabel}
title={contextPanelLabel}
>
{isContextPanelOpen ? (
<IconLayoutSidebarRightFilled className="size-4" />
) : (
<IconLayoutSidebarRight className="size-4" />
)}
</Button>
</div>
<ChatContextPanel
activeSessionId={activeSessionId}
isOpen={isContextPanelOpen}
label={contextPanelLabel}
project={project}
setOpen={setContextPanelOpen}
/>
</div>
</ArtifactPolicyProvider>
);
@@ -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(
<AgentModelPicker
agents={AGENTS}
selectedAgentId="goose"
onAgentChange={vi.fn()}
currentModelId="gpt-4o"
currentModelName="GPT-4o"
availableModels={[{ id: "gpt-4o", name: "GPT-4o" }]}
onModelChange={vi.fn()}
/>,
);
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(
<AgentModelPicker
agents={AGENTS}
selectedAgentId="goose"
onAgentChange={vi.fn()}
currentModelId="claude-sonnet-4"
currentModelName="Claude Sonnet 4"
availableModels={[
{ id: "claude-sonnet-4", name: "Claude Sonnet 4" },
{ id: "gpt-4o", name: "GPT-4o" },
]}
onModelChange={onModelChange}
/>,
);
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(
<AgentModelPicker
agents={AGENTS}
selectedAgentId="goose"
onAgentChange={vi.fn()}
currentModelId="claude-sonnet-4"
currentModelName="Claude Sonnet 4"
availableModels={[
{ id: "claude-sonnet-4", name: "Claude Sonnet 4" },
{ id: "gpt-4o", name: "GPT-4o" },
]}
onModelChange={vi.fn()}
/>,
);
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(
<AgentModelPicker
agents={AGENTS}
selectedAgentId="goose"
onAgentChange={vi.fn()}
currentModelId={null}
currentModelName={null}
availableModels={[]}
onModelChange={vi.fn()}
/>,
);
const trigger = screen.getByRole("button", {
name: /choose agent and model/i,
});
expect(trigger).toHaveTextContent("Goose");
expect(trigger).not.toHaveTextContent("·");
});
});
@@ -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(
<ChatInput
onSend={vi.fn()}
currentModelId="gpt-4o"
currentModel="GPT-4o"
availableModels={[{ id: "gpt-4o", name: "GPT-4o" }]}
providers={[{ id: "goose", label: "Goose" }]}
/>,
);
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", () => {
<ChatInput
onSend={vi.fn()}
availableModels={[{ id: "claude-sonnet-4", name: "Claude Sonnet 4" }]}
providers={[{ id: "goose", label: "Goose" }]}
/>,
);
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 () => {
@@ -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(<HomeScreen />);
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 }),
@@ -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<string>;
loading: boolean;
refresh: () => Promise<void>;
}
async function checkAgentProviderReady(providerId: string): Promise<boolean> {
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<string>(["goose"]);
export function useAgentProviderStatus(): UseAgentProviderStatusReturn {
const [readyAgentIds, setReadyAgentIds] =
useState<Set<string>>(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,
};
}
@@ -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");
});
});
@@ -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;
}
@@ -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<string, string> = {
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"],
];
@@ -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<SectionId>("appearance");
const [activeSection, setActiveSection] = useState<SectionId>(initialSection);
const [isLoaded, setIsLoaded] = useState(false);
const [isTransitioning, setIsTransitioning] = useState(false);
const [archivedProjects, setArchivedProjects] = useState<ProjectInfo[]>([]);
+10
View File
@@ -59,6 +59,16 @@ export async function acpPrepareSession(
});
}
export async function acpSetModel(
sessionId: string,
modelId: string,
): Promise<void> {
return invoke("acp_set_model", {
sessionId,
modelId,
});
}
/** Session info returned by the goose binary's list_sessions. */
export interface AcpSessionInfo {
sessionId: string;
@@ -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",
@@ -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",
+1
View File
@@ -55,6 +55,7 @@ export interface Session {
projectId?: string | null;
providerId?: string;
personaId?: string;
modelId?: string;
modelName?: string;
createdAt: string;
updatedAt: string;