diff --git a/apps/tauri/src-tauri/src/acp/commands.rs b/apps/tauri/src-tauri/src/acp/commands.rs index cea4f0b..c68a628 100644 --- a/apps/tauri/src-tauri/src/acp/commands.rs +++ b/apps/tauri/src-tauri/src/acp/commands.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; +use tauri::{AppHandle, Emitter, State}; use tokio::sync::Mutex; -use tauri::{AppHandle, State, Emitter}; use super::connection::AcpConnection; use super::types::*; @@ -34,7 +34,11 @@ pub async fn acp_connect( }; if let Some(existing) = existing { if existing.is_alive().await { - state.connections.lock().await.insert(workspace_id.clone(), existing); + state + .connections + .lock() + .await + .insert(workspace_id.clone(), existing); return Ok(()); } existing.shutdown().await; @@ -51,11 +55,14 @@ pub async fn acp_connect( gh_token: gh_token.clone(), }; - let _ = app_handle.emit("acp:connection-status", &ConnectionStatusEvent { - workspace_id: workspace_id.clone(), - status: "connecting".to_string(), - attempt: None, - }); + let _ = app_handle.emit( + "acp:connection-status", + &ConnectionStatusEvent { + workspace_id: workspace_id.clone(), + status: "connecting".to_string(), + attempt: None, + }, + ); let conn = AcpConnection::spawn( &binary, @@ -82,7 +89,11 @@ pub async fn acp_connect( conn.emit_status("connected", None); conn.emit_log("info", "connect", &format!("Connected via {}", binary)); - state.configs.lock().await.insert(workspace_id.clone(), config); + state + .configs + .lock() + .await + .insert(workspace_id.clone(), config); state.connections.lock().await.insert(workspace_id, conn); Ok(()) } @@ -108,9 +119,7 @@ pub async fn acp_new_session( state: State<'_, AcpState>, ) -> Result { let connections = state.connections.lock().await; - let conn = connections - .get(&workspace_id) - .ok_or("Not connected")?; + let conn = connections.get(&workspace_id).ok_or("Not connected")?; let params = NewSessionParams { cwd, @@ -136,9 +145,7 @@ pub async fn acp_list_sessions( state: State<'_, AcpState>, ) -> Result, String> { let connections = state.connections.lock().await; - let conn = connections - .get(&workspace_id) - .ok_or("Not connected")?; + let conn = connections.get(&workspace_id).ok_or("Not connected")?; let params = ListSessionsParams { cwd }; @@ -166,9 +173,7 @@ pub async fn acp_load_session( state: State<'_, AcpState>, ) -> Result { let connections = state.connections.lock().await; - let conn = connections - .get(&workspace_id) - .ok_or("Not connected")?; + let conn = connections.get(&workspace_id).ok_or("Not connected")?; let params = LoadSessionParams { session_id: session_id.clone(), @@ -200,9 +205,7 @@ pub async fn acp_send_prompt( state: State<'_, AcpState>, ) -> Result<(), String> { let connections = state.connections.lock().await; - let conn = connections - .get(&workspace_id) - .ok_or("Not connected")?; + let conn = connections.get(&workspace_id).ok_or("Not connected")?; let params = PromptParams { session_id, @@ -230,9 +233,7 @@ pub async fn acp_set_mode( state: State<'_, AcpState>, ) -> Result<(), String> { let connections = state.connections.lock().await; - let conn = connections - .get(&workspace_id) - .ok_or("Not connected")?; + let conn = connections.get(&workspace_id).ok_or("Not connected")?; let params = SetSessionModeParams { session_id, @@ -248,6 +249,42 @@ pub async fn acp_set_mode( Ok(()) } +#[tauri::command] +pub async fn acp_set_config_option( + workspace_id: String, + session_id: String, + config_id: String, + option_id: String, + state: State<'_, AcpState>, +) -> Result<(), String> { + acp_set_config_option_inner(&state, &workspace_id, &session_id, &config_id, &option_id).await +} + +async fn acp_set_config_option_inner( + state: &AcpState, + workspace_id: &str, + session_id: &str, + config_id: &str, + option_id: &str, +) -> Result<(), String> { + let connections = state.connections.lock().await; + let conn = connections.get(workspace_id).ok_or("Not connected")?; + + let params = SetConfigOptionParams { + session_id: session_id.to_string(), + config_id: config_id.to_string(), + value: option_id.to_string(), + }; + + conn.send_request( + "session/set_config_option", + Some(serde_json::to_value(¶ms).map_err(|e| e.to_string())?), + ) + .await?; + + Ok(()) +} + #[tauri::command] pub async fn acp_cancel( workspace_id: String, @@ -255,9 +292,7 @@ pub async fn acp_cancel( state: State<'_, AcpState>, ) -> Result<(), String> { let connections = state.connections.lock().await; - let conn = connections - .get(&workspace_id) - .ok_or("Not connected")?; + let conn = connections.get(&workspace_id).ok_or("Not connected")?; let params = CancelParams { session_id }; @@ -283,7 +318,11 @@ pub async fn acp_check_health( let status = if let Some(conn) = existing { if conn.is_alive().await { conn.emit_status("connected", None); - state.connections.lock().await.insert(workspace_id.clone(), conn); + state + .connections + .lock() + .await + .insert(workspace_id.clone(), conn); "connected" } else { conn.emit_status("disconnected", None); @@ -311,3 +350,17 @@ pub async fn disconnect_all(state: &AcpState) { conn.shutdown().await; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn set_config_option_returns_not_connected_when_workspace_missing() { + let state = AcpState::default(); + let result = tauri::async_runtime::block_on(async { + acp_set_config_option_inner(&state, "ws-1", "s-1", "model", "sonnet").await + }); + assert_eq!(result.unwrap_err(), "Not connected"); + } +} diff --git a/apps/tauri/src-tauri/src/acp/types.rs b/apps/tauri/src-tauri/src/acp/types.rs index 9ffb90d..418f6e4 100644 --- a/apps/tauri/src-tauri/src/acp/types.rs +++ b/apps/tauri/src-tauri/src/acp/types.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::collections::HashMap; #[derive(Debug, Serialize)] pub struct JsonRpcRequest { @@ -84,6 +85,14 @@ pub struct SetSessionModeParams { pub mode_id: String, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetConfigOptionParams { + pub session_id: String, + pub config_id: String, + pub value: String, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ListSessionsParams { @@ -102,6 +111,7 @@ pub struct SessionInfo { #[serde(default)] pub session_id: String, pub modes: Option, + pub config_options: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -120,6 +130,38 @@ pub struct SessionMode { pub description: Option, } +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionConfigOptionsState { + #[serde(default)] + pub available_config_options: Vec, + #[serde(default)] + pub selected_config_options: HashMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionConfigOption { + pub id: String, + pub name: Option, + pub description: Option, + pub category: Option, + #[serde(rename = "type")] + pub option_type: Option, + #[serde(default)] + pub options: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionConfigOptionValue { + pub id: Option, + pub value: Option, + pub name: Option, + pub label: Option, + pub description: Option, +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct SessionSummary { @@ -173,3 +215,22 @@ pub struct ConnectionConfig { pub cwd: String, pub gh_token: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn set_config_option_params_serializes_option_id_as_value() { + let params = SetConfigOptionParams { + session_id: "s-1".to_string(), + config_id: "model".to_string(), + value: "claude-sonnet".to_string(), + }; + + let value = serde_json::to_value(params).expect("serialize SetConfigOptionParams"); + assert_eq!(value["sessionId"], "s-1"); + assert_eq!(value["configId"], "model"); + assert_eq!(value["value"], "claude-sonnet"); + } +} diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index d18a3ca..dacf33d 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -14,11 +14,11 @@ mod acp; mod cli_installer; mod comments; mod history; -mod plan_file; -mod sessions; -mod ipc_common; #[cfg(unix)] mod ipc; +mod ipc_common; +mod plan_file; +mod sessions; mod tcp_ipc; mod tray; mod whisper; @@ -131,9 +131,13 @@ fn create_file_watcher(app: tauri::AppHandle) -> Result) -> Result<(), String> { - let canonical = std::fs::canonicalize(&path) - .map_err(|e| format!("Erro ao canonicalizar: {}", e))?; +fn watch_file( + path: String, + app: tauri::AppHandle, + state: tauri::State, +) -> Result<(), String> { + let canonical = + std::fs::canonicalize(&path).map_err(|e| format!("Erro ao canonicalizar: {}", e))?; let mut watched = state.watched_paths.lock().map_err(|e| e.to_string())?; @@ -147,7 +151,8 @@ fn watch_file(path: String, app: tauri::AppHandle, state: tauri::State) -> Result<(), String> { - let canonical = std::fs::canonicalize(&path) - .map_err(|e| format!("Arquivo não encontrado: {}", e))?; + let canonical = + std::fs::canonicalize(&path).map_err(|e| format!("Arquivo não encontrado: {}", e))?; let mut watched = state.watched_paths.lock().map_err(|e| e.to_string())?; @@ -180,7 +185,9 @@ fn get_initial_file(state: tauri::State) -> Option { #[tauri::command] fn get_home_dir() -> Option { - std::env::var("HOME").ok().or_else(|| std::env::var("USERPROFILE").ok()) + std::env::var("HOME") + .ok() + .or_else(|| std::env::var("USERPROFILE").ok()) } #[tauri::command] @@ -255,7 +262,10 @@ pub struct DiagnosticsResult { acp_stderr: Option, } -async fn test_acp_connection(binary: &str, gh_token: Option<&str>) -> (bool, Option, Option, String, Option) { +async fn test_acp_connection( + binary: &str, + gh_token: Option<&str>, +) -> (bool, Option, Option, String, Option) { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; let timeout = std::time::Duration::from_secs(10); @@ -276,23 +286,53 @@ async fn test_acp_connection(binary: &str, gh_token: Option<&str>) -> (bool, Opt let mut child = match cmd.spawn() { Ok(c) => c, - Err(e) => return (false, None, Some(format!("spawn failed: {}", e)), command_str, None), + Err(e) => { + return ( + false, + None, + Some(format!("spawn failed: {}", e)), + command_str, + None, + ) + } }; let mut stdin = match child.stdin.take() { Some(s) => s, - None => return (false, None, Some("failed to capture stdin".to_string()), command_str, None), + None => { + return ( + false, + None, + Some("failed to capture stdin".to_string()), + command_str, + None, + ) + } }; let stdout = match child.stdout.take() { Some(s) => s, - None => return (false, None, Some("failed to capture stdout".to_string()), command_str, None), + None => { + return ( + false, + None, + Some("failed to capture stdout".to_string()), + command_str, + None, + ) + } }; let stderr = child.stderr.take(); let init_msg = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}"# .to_string() + "\n"; if let Err(e) = stdin.write_all(init_msg.as_bytes()).await { - return (false, None, Some(format!("write failed: {}", e)), command_str, None); + return ( + false, + None, + Some(format!("write failed: {}", e)), + command_str, + None, + ); } let _ = stdin.flush().await; @@ -301,19 +341,21 @@ async fn test_acp_connection(binary: &str, gh_token: Option<&str>) -> (bool, Opt if let Some(s) = stderr { let mut reader = BufReader::new(s); let mut buf = String::new(); - let _ = tokio::time::timeout( - std::time::Duration::from_secs(10), - async { - loop { - let mut line = String::new(); - match reader.read_line(&mut line).await { - Ok(0) | Err(_) => break, - Ok(_) => buf.push_str(&line), - } + let _ = tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + let mut line = String::new(); + match reader.read_line(&mut line).await { + Ok(0) | Err(_) => break, + Ok(_) => buf.push_str(&line), } } - ).await; - if buf.is_empty() { None } else { Some(buf.trim().to_string()) } + }) + .await; + if buf.is_empty() { + None + } else { + Some(buf.trim().to_string()) + } } else { None } @@ -324,7 +366,9 @@ async fn test_acp_connection(binary: &str, gh_token: Option<&str>) -> (bool, Opt let mut lines = reader.lines(); while let Ok(Some(line)) = lines.next_line().await { let line = line.trim().to_string(); - if line.is_empty() { continue; } + if line.is_empty() { + continue; + } if let Ok(v) = serde_json::from_str::(&line) { if v.get("id").and_then(|i| i.as_u64()) == Some(1) { if v.get("error").is_some() { @@ -342,31 +386,50 @@ async fn test_acp_connection(binary: &str, gh_token: Option<&str>) -> (bool, Opt Ok(Ok(())) => { let _ = child.kill().await; let stderr_out = stderr_task.await.ok().flatten(); - (true, Some(start.elapsed().as_millis() as u64), None, command_str, stderr_out) + ( + true, + Some(start.elapsed().as_millis() as u64), + None, + command_str, + stderr_out, + ) } Ok(Err(e)) => { let _ = child.kill().await; let stderr_out = stderr_task.await.ok().flatten(); - (false, Some(start.elapsed().as_millis() as u64), Some(e), command_str, stderr_out) + ( + false, + Some(start.elapsed().as_millis() as u64), + Some(e), + command_str, + stderr_out, + ) } Err(_) => { let _ = child.kill().await; let stderr_out = stderr_task.await.ok().flatten(); - (false, Some(start.elapsed().as_millis() as u64), Some("timeout after 10s — binary may be waiting for auth or network".to_string()), command_str, stderr_out) + ( + false, + Some(start.elapsed().as_millis() as u64), + Some("timeout after 10s — binary may be waiting for auth or network".to_string()), + command_str, + stderr_out, + ) } } } #[tauri::command] -async fn run_diagnostics(binary_path: Option, gh_token: Option) -> DiagnosticsResult { +async fn run_diagnostics( + binary_path: Option, + gh_token: Option, +) -> DiagnosticsResult { let binary = binary_path .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) .map(ToOwned::to_owned) - .unwrap_or_else(|| { - std::env::var("COPILOT_PATH").unwrap_or_else(|_| "copilot".to_string()) - }); + .unwrap_or_else(|| std::env::var("COPILOT_PATH").unwrap_or_else(|_| "copilot".to_string())); let platform = std::env::consts::OS.to_string(); let arch = std::env::consts::ARCH.to_string(); @@ -416,7 +479,13 @@ async fn run_diagnostics(binary_path: Option, gh_token: Option) let token_ref = gh_token.as_deref(); test_acp_connection(&binary, token_ref).await } else { - (false, None, Some("binary not found — skipping ACP test".to_string()), format!("{} --acp --stdio", binary), None) + ( + false, + None, + Some("binary not found — skipping ACP test".to_string()), + format!("{} --acp --stdio", binary), + None, + ) }; DiagnosticsResult { @@ -508,9 +577,8 @@ fn count_unresolved_comments( #[tauri::command] fn hash_file(path: String) -> Result { - use sha2::{Sha256, Digest}; - let content = std::fs::read(&path) - .map_err(|e| format!("Read error: {}", e))?; + use sha2::{Digest, Sha256}; + let content = std::fs::read(&path).map_err(|e| format!("Read error: {}", e))?; let hash = Sha256::digest(&content); Ok(format!("{:x}", hash)) } @@ -637,8 +705,7 @@ fn rebuild_macos_menu( let settings_item = MenuItemBuilder::with_id("settings", settings) .accelerator("CmdOrCtrl+,") .build(app)?; - let install_cli_item = MenuItemBuilder::with_id("install-cli", install_cli) - .build(app)?; + let install_cli_item = MenuItemBuilder::with_id("install-cli", install_cli).build(app)?; let open_file_item = MenuItemBuilder::with_id("open-file", open_file) .accelerator("CmdOrCtrl+O") .build(app)?; @@ -701,22 +768,20 @@ fn setup_macos_menu(app: &tauri::App) -> Result<(), Box> )?; let app_handle = app.handle().clone(); - app.on_menu_event(move |_app, event| { - match event.id().as_ref() { - "settings" => { - if let Some(window) = app_handle.get_webview_window("settings") { - let _ = window.show(); - let _ = window.set_focus(); - } - } - "install-cli" => { - let _ = app_handle.emit("menu-install-cli", ()); - } - "open-file" => { - let _ = app_handle.emit("menu-open-file", ()); + app.on_menu_event(move |_app, event| match event.id().as_ref() { + "settings" => { + if let Some(window) = app_handle.get_webview_window("settings") { + let _ = window.show(); + let _ = window.set_focus(); } - _ => {} } + "install-cli" => { + let _ = app_handle.emit("menu-install-cli", ()); + } + "open-file" => { + let _ = app_handle.emit("menu-open-file", ()); + } + _ => {} }); Ok(()) @@ -736,10 +801,10 @@ pub fn run() { .with_denylist(&["whisper", "settings"]) .with_state_flags( tauri_plugin_window_state::StateFlags::SIZE - | tauri_plugin_window_state::StateFlags::POSITION - | tauri_plugin_window_state::StateFlags::MAXIMIZED + | tauri_plugin_window_state::StateFlags::POSITION + | tauri_plugin_window_state::StateFlags::MAXIMIZED, ) - .build() + .build(), ) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { eprintln!("[DEBUG] Second instance detected: {:?}", args); @@ -824,10 +889,12 @@ pub fn run() { eprintln!("Failed to setup TCP IPC: {}", e); } - let app_data = app.path().app_data_dir() + let app_data = app + .path() + .app_data_dir() .map_err(|e| Box::new(e) as Box)?; - let conn = comments::init_db(&app_data) - .map_err(|e| Box::::from(e))?; + let conn = + comments::init_db(&app_data).map_err(|e| Box::::from(e))?; sessions::init_sessions_table(&conn) .map_err(|e| Box::::from(e))?; app.manage(comments::CommentsDb(Mutex::new(conn))); @@ -841,20 +908,29 @@ pub fn run() { let handle = app.handle().clone(); - let register = app.global_shortcut().on_shortcut(shortcut_str.as_str(), move |_app, _shortcut, event| { - if let ShortcutState::Pressed = event.state { - handle_recording_toggle(&handle); - } - }); - - if let Err(e) = register { - eprintln!("Invalid shortcut '{}': {e}. Falling back to default.", shortcut_str); - let handle = app.handle().clone(); - if let Err(e) = app.global_shortcut().on_shortcut(whisper::model_manager::DEFAULT_SHORTCUT, move |_app, _shortcut, event| { + let register = app.global_shortcut().on_shortcut( + shortcut_str.as_str(), + move |_app, _shortcut, event| { if let ShortcutState::Pressed = event.state { handle_recording_toggle(&handle); } - }) { + }, + ); + + if let Err(e) = register { + eprintln!( + "Invalid shortcut '{}': {e}. Falling back to default.", + shortcut_str + ); + let handle = app.handle().clone(); + if let Err(e) = app.global_shortcut().on_shortcut( + whisper::model_manager::DEFAULT_SHORTCUT, + move |_app, _shortcut, event| { + if let ShortcutState::Pressed = event.state { + handle_recording_toggle(&handle); + } + }, + ) { eprintln!("Failed to register default shortcut: {e}"); } } @@ -867,21 +943,30 @@ pub fn run() { }; let cancel_handle = app.handle().clone(); - if let Err(e) = app.global_shortcut().on_shortcut(cancel_shortcut_str.as_str(), move |_app, _shortcut, event| { - if let ShortcutState::Pressed = event.state { - handle_recording_cancel(&cancel_handle); - } - }) { - eprintln!("Failed to register cancel shortcut '{}': {e}", cancel_shortcut_str); + if let Err(e) = app.global_shortcut().on_shortcut( + cancel_shortcut_str.as_str(), + move |_app, _shortcut, event| { + if let ShortcutState::Pressed = event.state { + handle_recording_cancel(&cancel_handle); + } + }, + ) { + eprintln!( + "Failed to register cancel shortcut '{}': {e}", + cancel_shortcut_str + ); } // Auto-load saved whisper model if let Ok(app_data_dir) = app.path().app_data_dir() { let settings = whisper::model_manager::load_settings(&app_data_dir); if let Some(model_id) = &settings.active_model { - if let Some(path) = whisper::model_manager::model_path(&app_data_dir, model_id) { + if let Some(path) = whisper::model_manager::model_path(&app_data_dir, model_id) + { if path.exists() { - if let Ok(transcriber) = whisper::transcriber::WhisperTranscriber::new(&path.to_string_lossy()) { + if let Ok(transcriber) = whisper::transcriber::WhisperTranscriber::new( + &path.to_string_lossy(), + ) { let state = app.state::(); let mut guard = state.0.lock().unwrap(); *guard = Some(transcriber); @@ -972,6 +1057,7 @@ pub fn run() { acp::commands::acp_load_session, acp::commands::acp_send_prompt, acp::commands::acp_set_mode, + acp::commands::acp_set_config_option, acp::commands::acp_cancel, acp::commands::acp_check_health, sessions::count_workspace_sessions, @@ -979,6 +1065,9 @@ pub fn run() { sessions::session_create, sessions::session_get, sessions::session_update_acp_id, + sessions::session_update_acp_preferences, + sessions::workspace_acp_defaults_get, + sessions::workspace_acp_defaults_set, sessions::session_update_plan, sessions::session_update_plan_file_path, sessions::session_update_phase, diff --git a/apps/tauri/src-tauri/src/sessions.rs b/apps/tauri/src-tauri/src/sessions.rs index e4ef27d..f45769d 100644 --- a/apps/tauri/src-tauri/src/sessions.rs +++ b/apps/tauri/src-tauri/src/sessions.rs @@ -1,4 +1,4 @@ -use rusqlite::{params, Connection}; +use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize, Clone)] @@ -6,6 +6,7 @@ pub struct SessionRecord { pub id: String, pub workspace_path: String, pub acp_session_id: Option, + pub acp_preferences_json: String, pub name: String, pub initial_prompt: String, pub plan_markdown: String, @@ -22,15 +23,22 @@ pub fn init_sessions_table(conn: &Connection) -> Result<(), String> { id TEXT PRIMARY KEY, workspace_path TEXT NOT NULL, acp_session_id TEXT, + acp_preferences_json TEXT NOT NULL DEFAULT '{}', name TEXT NOT NULL, initial_prompt TEXT NOT NULL, plan_markdown TEXT DEFAULT '', plan_file_path TEXT, phase TEXT DEFAULT 'idle', + chat_panel_size REAL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); - CREATE INDEX IF NOT EXISTS idx_sessions_workspace ON sessions(workspace_path);" + CREATE INDEX IF NOT EXISTS idx_sessions_workspace ON sessions(workspace_path); + CREATE TABLE IF NOT EXISTS workspace_acp_defaults ( + workspace_path TEXT PRIMARY KEY, + acp_preferences_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL + );", ) .map_err(|e| format!("Failed to create sessions table: {}", e))?; @@ -52,16 +60,40 @@ pub fn init_sessions_table(conn: &Connection) -> Result<(), String> { .map_err(|e| format!("Migration failed: {}", e))?; } + // Migration: add acp_preferences_json column + let has_acp_preferences_json: bool = conn + .prepare("SELECT acp_preferences_json FROM sessions LIMIT 0") + .is_ok(); + if !has_acp_preferences_json { + conn.execute_batch( + "ALTER TABLE sessions ADD COLUMN acp_preferences_json TEXT NOT NULL DEFAULT '{}';", + ) + .map_err(|e| format!("Migration failed: {}", e))?; + } + + // Ensure workspace ACP defaults table exists for older DBs. + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS workspace_acp_defaults ( + workspace_path TEXT PRIMARY KEY, + acp_preferences_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL + );", + ) + .map_err(|e| format!("Failed to create workspace_acp_defaults table: {}", e))?; + Ok(()) } -pub fn list_sessions(conn: &Connection, workspace_path: &str) -> Result, String> { +pub fn list_sessions( + conn: &Connection, + workspace_path: &str, +) -> Result, String> { let mut stmt = conn .prepare( - "SELECT id, workspace_path, acp_session_id, name, initial_prompt, + "SELECT id, workspace_path, acp_session_id, acp_preferences_json, name, initial_prompt, plan_markdown, plan_file_path, phase, chat_panel_size, created_at, updated_at FROM sessions WHERE workspace_path = ?1 - ORDER BY updated_at DESC" + ORDER BY updated_at DESC", ) .map_err(|e| format!("Query prepare error: {}", e))?; @@ -71,14 +103,15 @@ pub fn list_sessions(conn: &Connection, workspace_path: &str) -> Result Result Result { conn.query_row( - "SELECT id, workspace_path, acp_session_id, name, initial_prompt, + "SELECT id, workspace_path, acp_session_id, acp_preferences_json, name, initial_prompt, plan_markdown, plan_file_path, phase, chat_panel_size, created_at, updated_at FROM sessions WHERE id = ?1", params![id], @@ -99,14 +132,15 @@ pub fn get_session(conn: &Connection, id: &str) -> Result id: row.get(0)?, workspace_path: row.get(1)?, acp_session_id: row.get(2)?, - name: row.get(3)?, - initial_prompt: row.get(4)?, - plan_markdown: row.get(5)?, - plan_file_path: row.get(6)?, - phase: row.get(7)?, - chat_panel_size: row.get(8)?, - created_at: row.get(9)?, - updated_at: row.get(10)?, + acp_preferences_json: row.get(3)?, + name: row.get(4)?, + initial_prompt: row.get(5)?, + plan_markdown: row.get(6)?, + plan_file_path: row.get(7)?, + phase: row.get(8)?, + chat_panel_size: row.get(9)?, + created_at: row.get(10)?, + updated_at: row.get(11)?, }) }, ) @@ -123,8 +157,8 @@ pub fn create_session( let now = chrono::Utc::now().to_rfc3339(); conn.execute( - "INSERT INTO sessions (id, workspace_path, name, initial_prompt, plan_markdown, phase, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, '', 'idle', ?5, ?5)", + "INSERT INTO sessions (id, workspace_path, acp_preferences_json, name, initial_prompt, plan_markdown, phase, created_at, updated_at) + VALUES (?1, ?2, '{}', ?3, ?4, '', 'idle', ?5, ?5)", params![id, workspace_path, name, initial_prompt, now], ) .map_err(|e| format!("Insert error: {}", e))?; @@ -146,6 +180,63 @@ pub fn update_session_acp_id( Ok(()) } +pub fn update_session_acp_preferences( + conn: &Connection, + id: &str, + acp_preferences_json: &str, +) -> Result<(), String> { + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE sessions SET acp_preferences_json = ?1, updated_at = ?2 WHERE id = ?3", + params![acp_preferences_json, now, id], + ) + .map_err(|e| format!("Update ACP preferences error: {}", e))?; + Ok(()) +} + +pub fn get_workspace_acp_defaults( + conn: &Connection, + workspace_path: &str, +) -> Result, String> { + conn.query_row( + "SELECT acp_preferences_json FROM workspace_acp_defaults WHERE workspace_path = ?1", + params![workspace_path], + |row| row.get(0), + ) + .optional() + .map_err(|e| format!("Read workspace ACP defaults error: {}", e)) +} + +pub fn set_workspace_acp_defaults( + conn: &Connection, + workspace_path: &str, + acp_preferences_json: &str, +) -> Result<(), String> { + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO workspace_acp_defaults (workspace_path, acp_preferences_json, updated_at) + VALUES (?1, ?2, ?3) + ON CONFLICT(workspace_path) DO UPDATE SET + acp_preferences_json = excluded.acp_preferences_json, + updated_at = excluded.updated_at", + params![workspace_path, acp_preferences_json, now], + ) + .map_err(|e| format!("Write workspace ACP defaults error: {}", e))?; + Ok(()) +} + +pub fn delete_workspace_acp_defaults( + conn: &Connection, + workspace_path: &str, +) -> Result<(), String> { + conn.execute( + "DELETE FROM workspace_acp_defaults WHERE workspace_path = ?1", + params![workspace_path], + ) + .map_err(|e| format!("Delete workspace ACP defaults error: {}", e))?; + Ok(()) +} + pub fn update_plan(conn: &Connection, id: &str, markdown: &str) -> Result<(), String> { let now = chrono::Utc::now().to_rfc3339(); conn.execute( @@ -159,7 +250,10 @@ pub fn update_plan(conn: &Connection, id: &str, markdown: &str) -> Result<(), St pub fn update_phase(conn: &Connection, id: &str, phase: &str) -> Result<(), String> { let valid = ["idle", "planning", "reviewing", "executing", "done"]; if !valid.contains(&phase) { - return Err(format!("Invalid phase: {}. Must be one of: {:?}", phase, valid)); + return Err(format!( + "Invalid phase: {}. Must be one of: {:?}", + phase, valid + )); } let now = chrono::Utc::now().to_rfc3339(); conn.execute( @@ -170,7 +264,11 @@ pub fn update_phase(conn: &Connection, id: &str, phase: &str) -> Result<(), Stri Ok(()) } -pub fn update_plan_file_path(conn: &Connection, id: &str, plan_file_path: &str) -> Result<(), String> { +pub fn update_plan_file_path( + conn: &Connection, + id: &str, + plan_file_path: &str, +) -> Result<(), String> { let now = chrono::Utc::now().to_rfc3339(); conn.execute( "UPDATE sessions SET plan_file_path = ?1, updated_at = ?2 WHERE id = ?3", @@ -195,7 +293,10 @@ pub fn delete_session(conn: &Connection, id: &str) -> Result<(), String> { Ok(()) } -pub fn count_sessions_batch(conn: &Connection, workspace_paths: &[String]) -> Result, String> { +pub fn count_sessions_batch( + conn: &Connection, + workspace_paths: &[String], +) -> Result, String> { if workspace_paths.is_empty() { return Ok(Vec::new()); } @@ -209,10 +310,15 @@ pub fn count_sessions_batch(conn: &Connection, workspace_paths: &[String]) -> Re "SELECT workspace_path, COUNT(*) FROM sessions WHERE workspace_path IN ({}) GROUP BY workspace_path HAVING COUNT(*) > 0", placeholders.join(", ") ); - let mut stmt = conn.prepare(&sql).map_err(|e| format!("Prepare error: {}", e))?; - let params: Vec<&dyn rusqlite::ToSql> = chunk.iter().map(|p| p as &dyn rusqlite::ToSql).collect(); + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("Prepare error: {}", e))?; + let params: Vec<&dyn rusqlite::ToSql> = + chunk.iter().map(|p| p as &dyn rusqlite::ToSql).collect(); let rows = stmt - .query_map(params.as_slice(), |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))) + .query_map(params.as_slice(), |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + }) .map_err(|e| format!("Query error: {}", e))? .collect::, _>>() .map_err(|e| format!("Row error: {}", e))?; @@ -222,7 +328,10 @@ pub fn count_sessions_batch(conn: &Connection, workspace_paths: &[String]) -> Re Ok(results) } -pub fn delete_workspace_sessions(conn: &Connection, workspace_path: &str) -> Result, String> { +pub fn delete_workspace_sessions( + conn: &Connection, + workspace_path: &str, +) -> Result, String> { let mut stmt = conn .prepare("SELECT id FROM sessions WHERE workspace_path = ?1") .map_err(|e| format!("Query error: {}", e))?; @@ -232,8 +341,11 @@ pub fn delete_workspace_sessions(conn: &Connection, workspace_path: &str) -> Res .collect::, _>>() .map_err(|e| format!("Row error: {}", e))?; - conn.execute("DELETE FROM sessions WHERE workspace_path = ?1", params![workspace_path]) - .map_err(|e| format!("Delete error: {}", e))?; + conn.execute( + "DELETE FROM sessions WHERE workspace_path = ?1", + params![workspace_path], + ) + .map_err(|e| format!("Delete error: {}", e))?; Ok(ids) } @@ -273,10 +385,7 @@ pub fn session_create( } #[tauri::command] -pub fn session_get( - id: String, - db: tauri::State, -) -> Result { +pub fn session_get(id: String, db: tauri::State) -> Result { let conn = db.0.lock().map_err(|e| e.to_string())?; get_session(&conn, &id) } @@ -291,6 +400,35 @@ pub fn session_update_acp_id( update_session_acp_id(&conn, &id, &acp_session_id) } +#[tauri::command] +pub fn session_update_acp_preferences( + id: String, + acp_preferences_json: String, + db: tauri::State, +) -> Result<(), String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + update_session_acp_preferences(&conn, &id, &acp_preferences_json) +} + +#[tauri::command] +pub fn workspace_acp_defaults_get( + workspace_path: String, + db: tauri::State, +) -> Result, String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + get_workspace_acp_defaults(&conn, &workspace_path) +} + +#[tauri::command] +pub fn workspace_acp_defaults_set( + workspace_path: String, + acp_preferences_json: String, + db: tauri::State, +) -> Result<(), String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + set_workspace_acp_defaults(&conn, &workspace_path, &acp_preferences_json) +} + #[tauri::command] pub fn session_update_plan( id: String, @@ -339,7 +477,9 @@ pub fn session_delete( ) -> Result<(), String> { let conn = db.0.lock().map_err(|e| e.to_string())?; delete_session(&conn, &id)?; - let app_data = app.path().app_data_dir() + let app_data = app + .path() + .app_data_dir() .map_err(|e| format!("Failed to get app data dir: {}", e))?; crate::plan_file::delete_plan(&app_data, &id) } @@ -356,6 +496,7 @@ pub fn forget_workspace_data( match workspace_type.as_str() { "directory" => { crate::comments::delete_comments_for_workspace(&conn, &workspace_path)?; + delete_workspace_acp_defaults(&conn, &workspace_path)?; delete_workspace_sessions(&conn, &workspace_path)? } "file" => { @@ -366,7 +507,9 @@ pub fn forget_workspace_data( } }; - let app_data = app.path().app_data_dir() + let app_data = app + .path() + .app_data_dir() .map_err(|e| format!("Failed to get app data dir: {}", e))?; for id in &session_ids { let _ = crate::plan_file::delete_plan(&app_data, id); @@ -374,3 +517,64 @@ pub fn forget_workspace_data( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn has_column(conn: &Connection, table: &str, column: &str) -> bool { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info({})", table)) + .expect("prepare table_info"); + let rows = stmt + .query_map([], |row| row.get::<_, String>(1)) + .expect("query table_info") + .collect::, _>>() + .expect("collect table_info"); + rows.iter().any(|c| c == column) + } + + #[test] + fn init_sessions_table_is_idempotent_with_new_columns_and_tables() { + let conn = Connection::open_in_memory().expect("open in-memory db"); + + init_sessions_table(&conn).expect("first init"); + init_sessions_table(&conn).expect("second init"); + + assert!(has_column(&conn, "sessions", "acp_preferences_json")); + assert!(has_column(&conn, "sessions", "chat_panel_size")); + + let table_exists: Option = conn + .query_row( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'workspace_acp_defaults'", + [], + |row| row.get(0), + ) + .optional() + .expect("query sqlite_master"); + assert_eq!(table_exists.as_deref(), Some("workspace_acp_defaults")); + } + + #[test] + fn persists_session_preferences_and_workspace_defaults() { + let conn = Connection::open_in_memory().expect("open in-memory db"); + init_sessions_table(&conn).expect("init sessions table"); + + let session = create_session(&conn, "/tmp/ws", "name", "prompt").expect("create session"); + update_session_acp_preferences(&conn, &session.id, r#"{"modeId":"plan"}"#) + .expect("update session acp prefs"); + + let loaded = get_session(&conn, &session.id).expect("load session"); + assert_eq!(loaded.acp_preferences_json, r#"{"modeId":"plan"}"#); + + set_workspace_acp_defaults(&conn, "/tmp/ws", r#"{"modeId":"agent"}"#) + .expect("set workspace defaults"); + let defaults = + get_workspace_acp_defaults(&conn, "/tmp/ws").expect("get workspace defaults"); + assert_eq!(defaults.as_deref(), Some(r#"{"modeId":"agent"}"#)); + + delete_workspace_acp_defaults(&conn, "/tmp/ws").expect("delete workspace defaults"); + let deleted = get_workspace_acp_defaults(&conn, "/tmp/ws").expect("get deleted defaults"); + assert!(deleted.is_none()); + } +} diff --git a/apps/tauri/src/__tests__/components/AcpSessionControls.test.tsx b/apps/tauri/src/__tests__/components/AcpSessionControls.test.tsx new file mode 100644 index 0000000..58dc308 --- /dev/null +++ b/apps/tauri/src/__tests__/components/AcpSessionControls.test.tsx @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { AcpSessionControls } from "@/components/AcpSessionControls"; + +const MODES = [ + { + id: "https://agentclientprotocol.com/protocol/session-modes#ask", + name: "Ask", + }, + { + id: "https://agentclientprotocol.com/protocol/session-modes#plan", + name: "Plan", + }, +]; + +const CONFIG_OPTIONS = [ + { + id: "model", + name: "Model", + category: "model", + options: [{ id: "m1", label: "Model 1" }, { id: "m2", label: "Model 2" }], + }, + { + id: "preferred_agent", + name: "Agent", + options: [{ id: "a1", label: "Agent 1" }, { id: "a2", label: "Agent 2" }], + }, + { + id: "temperature", + name: "Temperature", + options: [{ id: "low", label: "Low" }, { id: "high", label: "High" }], + }, +]; + +describe("AcpSessionControls", () => { + it("renders all chips in normal state", () => { + render( + + ); + + expect(screen.getByRole("button", { name: /mode:/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /model:/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /agent:/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /advanced:/i })).toBeInTheDocument(); + }); + + it("disables controls in streaming/disconnected state", () => { + render( + + ); + + expect(screen.getByRole("button", { name: /mode:/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /model:/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /agent:/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /advanced:/i })).toBeDisabled(); + }); + + it("shows unavailable model/agent when ACP does not provide them", () => { + render( + + ); + + expect(screen.getByRole("button", { name: /model:/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /agent:/i })).toBeDisabled(); + expect(screen.getAllByText(/Unavailable from ACP/i).length).toBeGreaterThan(0); + }); + + it("invokes callbacks on selector actions", async () => { + const user = userEvent.setup(); + const onSelectMode = vi.fn(); + const onSelectConfigOption = vi.fn(); + + render( + + ); + + await user.click(screen.getByRole("button", { name: /mode:/i })); + await user.click(screen.getByText("Plan")); + expect(onSelectMode).toHaveBeenCalledWith( + "https://agentclientprotocol.com/protocol/session-modes#plan" + ); + + await user.click(screen.getByRole("button", { name: /model:/i })); + await user.click(screen.getByText("Model 2")); + expect(onSelectConfigOption).toHaveBeenCalledWith("model", "m2"); + }); +}); diff --git a/apps/tauri/src/__tests__/hooks/useAcpSession.test.ts b/apps/tauri/src/__tests__/hooks/useAcpSession.test.ts new file mode 100644 index 0000000..e923bcf --- /dev/null +++ b/apps/tauri/src/__tests__/hooks/useAcpSession.test.ts @@ -0,0 +1,277 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useAcpSession } from "@/hooks/useAcpSession"; +import { clearWorkspaceCaches, sessionStore, type SessionEntry } from "@/lib/session-cache"; +import type { AcpSessionUpdate } from "@/types/acp"; + +const mockInvoke = globalThis.__TAURI__.core.invoke as ReturnType; +const mockListen = globalThis.__TAURI__.event.listen as ReturnType; + +let sessionUpdateListener: ((event: { payload: AcpSessionUpdate }) => void) | null = null; + +const BASE_ENTRY: SessionEntry = { + messages: [], + activeAcpSessionId: "sid-1", + currentMode: "https://agentclientprotocol.com/protocol/session-modes#ask", + availableModes: [ + { + id: "https://agentclientprotocol.com/protocol/session-modes#ask", + name: "Ask", + }, + { + id: "https://agentclientprotocol.com/protocol/session-modes#plan", + name: "Plan", + }, + { + id: "https://agentclientprotocol.com/protocol/session-modes#agent", + name: "Agent", + }, + ], + availableConfigOptions: [ + { + id: "model", + name: "Model", + category: "model", + options: [{ id: "m1", label: "Model 1" }, { id: "m2", label: "Model 2" }], + }, + ], + selectedConfigOptions: { model: "m1" }, + agentPlanFilePath: null, + isStreaming: false, +}; + +beforeEach(() => { + mockInvoke.mockReset(); + mockListen.mockReset(); + clearWorkspaceCaches("ws-1"); + sessionStore.set("ws-1", { ...BASE_ENTRY }); + + mockListen.mockImplementation( + (eventName: string, handler: (event: { payload: AcpSessionUpdate }) => void) => { + if (eventName === "acp:session-update") { + sessionUpdateListener = handler; + } + return Promise.resolve(() => {}); + } + ); +}); + +describe("useAcpSession", () => { + it("parses mode/config update variants from ACP events", async () => { + const { result } = renderHook(() => + useAcpSession("ws-1", "/repo", "local-session-1", true) + ); + + await act(async () => { + await vi.waitFor(() => expect(sessionUpdateListener).toBeTruthy()); + }); + + act(() => { + sessionUpdateListener!({ + payload: { + workspaceId: "ws-1", + sessionId: "sid-1", + updateType: "current_mode_update", + payload: { modeId: "https://agentclientprotocol.com/protocol/session-modes#plan" }, + }, + }); + }); + expect(result.current.currentMode).toBe( + "https://agentclientprotocol.com/protocol/session-modes#plan" + ); + + act(() => { + sessionUpdateListener!({ + payload: { + workspaceId: "ws-1", + sessionId: "sid-1", + updateType: "config_option_update", + payload: { selectedConfigOptions: { model: "m2" } }, + }, + }); + }); + expect(result.current.selectedConfigOptions.model).toBe("m2"); + + act(() => { + sessionUpdateListener!({ + payload: { + workspaceId: "ws-1", + sessionId: "sid-1", + updateType: "config_options_update", + payload: { + configOptions: { + selectedConfigOptions: { model: "m1" }, + }, + }, + }, + }); + }); + expect(result.current.selectedConfigOptions.model).toBe("m1"); + }); + + it("applies session preferences over workspace defaults", async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === "acp_new_session") { + return Promise.resolve({ + sessionId: "sid-1", + modes: { + availableModes: BASE_ENTRY.availableModes, + currentModeId: BASE_ENTRY.currentMode, + }, + configOptions: { + availableConfigOptions: BASE_ENTRY.availableConfigOptions, + selectedConfigOptions: {}, + }, + }); + } + if (cmd === "session_get") { + return Promise.resolve({ + acp_preferences_json: + '{"modeId":"https://agentclientprotocol.com/protocol/session-modes#agent","selectedConfigOptions":{"model":"m2"}}', + }); + } + if (cmd === "workspace_acp_defaults_get") { + return Promise.resolve( + '{"modeId":"https://agentclientprotocol.com/protocol/session-modes#plan","selectedConfigOptions":{"model":"m1"}}' + ); + } + return Promise.resolve(); + }); + + const { result } = renderHook(() => + useAcpSession("ws-1", "/repo", "local-session-1", true) + ); + + await act(async () => { + await result.current.startSession(); + }); + + const setModeCalls = mockInvoke.mock.calls.filter(([cmd]) => cmd === "acp_set_mode"); + expect(setModeCalls).toHaveLength(1); + expect(setModeCalls[0][1]).toMatchObject({ + mode: "https://agentclientprotocol.com/protocol/session-modes#agent", + }); + + const setConfigCalls = mockInvoke.mock.calls.filter( + ([cmd]) => cmd === "acp_set_config_option" + ); + expect(setConfigCalls).toHaveLength(1); + expect(setConfigCalls[0][1]).toMatchObject({ + configId: "model", + optionId: "m2", + }); + }); + + it("falls back to workspace defaults when session preferences are empty and ignores stale IDs", async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === "acp_new_session") { + return Promise.resolve({ + sessionId: "sid-1", + modes: { + availableModes: BASE_ENTRY.availableModes, + currentModeId: BASE_ENTRY.currentMode, + }, + configOptions: { + availableConfigOptions: BASE_ENTRY.availableConfigOptions, + selectedConfigOptions: {}, + }, + }); + } + if (cmd === "session_get") { + return Promise.resolve({ + acp_preferences_json: "{}", + }); + } + if (cmd === "workspace_acp_defaults_get") { + return Promise.resolve( + '{"modeId":"https://agentclientprotocol.com/protocol/session-modes#plan","selectedConfigOptions":{"model":"invalid","agent":"a1"}}' + ); + } + return Promise.resolve(); + }); + + const { result } = renderHook(() => + useAcpSession("ws-1", "/repo", "local-session-1", true) + ); + + await act(async () => { + await result.current.startSession(); + }); + + const setModeCalls = mockInvoke.mock.calls.filter(([cmd]) => cmd === "acp_set_mode"); + expect(setModeCalls).toHaveLength(1); + expect(setModeCalls[0][1]).toMatchObject({ + mode: "https://agentclientprotocol.com/protocol/session-modes#plan", + }); + + const setConfigCalls = mockInvoke.mock.calls.filter( + ([cmd]) => cmd === "acp_set_config_option" + ); + expect(setConfigCalls).toHaveLength(0); + }); + + it("does not persist workflow-forced mode changes, but persists user changes", async () => { + mockInvoke.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useAcpSession("ws-1", "/repo", "local-session-1", true) + ); + + await act(async () => { + await result.current.setMode( + "https://agentclientprotocol.com/protocol/session-modes#plan", + { origin: "workflow" } + ); + }); + + expect( + mockInvoke.mock.calls.some(([cmd]) => cmd === "session_update_acp_preferences") + ).toBe(false); + + await act(async () => { + await result.current.setMode( + "https://agentclientprotocol.com/protocol/session-modes#agent", + { origin: "user" } + ); + }); + + await vi.waitFor(() => { + expect( + mockInvoke.mock.calls.some(([cmd]) => cmd === "session_update_acp_preferences") + ).toBe(true); + expect( + mockInvoke.mock.calls.some(([cmd]) => cmd === "workspace_acp_defaults_set") + ).toBe(true); + }); + }); + + it("setConfigOption invokes ACP and persistence commands", async () => { + mockInvoke.mockResolvedValue(undefined); + const { result } = renderHook(() => + useAcpSession("ws-1", "/repo", "local-session-1", true) + ); + + await act(async () => { + await result.current.setConfigOption("model", "m2"); + }); + + expect(mockInvoke).toHaveBeenCalledWith( + "acp_set_config_option", + expect.objectContaining({ + workspaceId: "ws-1", + sessionId: "sid-1", + configId: "model", + optionId: "m2", + }) + ); + + await vi.waitFor(() => { + expect( + mockInvoke.mock.calls.some(([cmd]) => cmd === "session_update_acp_preferences") + ).toBe(true); + expect( + mockInvoke.mock.calls.some(([cmd]) => cmd === "workspace_acp_defaults_set") + ).toBe(true); + }); + }); +}); diff --git a/apps/tauri/src/components/AcpSessionControls.tsx b/apps/tauri/src/components/AcpSessionControls.tsx new file mode 100644 index 0000000..681e317 --- /dev/null +++ b/apps/tauri/src/components/AcpSessionControls.tsx @@ -0,0 +1,304 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronDown } from "lucide-react"; +import type { AcpSessionConfigOption, AcpSessionMode } from "@/types/acp"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +interface AcpSessionControlsProps { + disabled: boolean; + currentModeId: string | null; + availableModes: AcpSessionMode[]; + configOptions: AcpSessionConfigOption[]; + selectedConfigOptions: Record; + onSelectMode: (modeId: string) => void; + onSelectConfigOption: (configId: string, optionId: string) => void; +} + +interface ResolvedOption { + id: string; + label: string; +} + +const CHIP_CLASS = + "flex items-center gap-1 px-2 py-0.5 rounded hover:bg-muted transition-colors flex-shrink-0 disabled:opacity-50 disabled:pointer-events-none"; + +function normalizeModeSlug(modeId: string): string { + const hash = modeId.split("#").pop(); + if (hash) return hash.toLowerCase(); + const parts = modeId.split("/"); + return parts[parts.length - 1]?.toLowerCase() ?? modeId.toLowerCase(); +} + +function normalizeSearch(value: string | undefined | null): string { + return (value ?? "").toLowerCase(); +} + +function resolveOptionId(option: unknown): string | null { + if (typeof option === "string") return option; + if (!option || typeof option !== "object") return null; + const optionRecord = option as Record; + return ( + (typeof optionRecord.optionId === "string" && optionRecord.optionId) || + (typeof optionRecord.id === "string" && optionRecord.id) || + (typeof optionRecord.value === "string" && optionRecord.value) || + null + ); +} + +function resolveOptionLabel(option: unknown, fallbackId: string): string { + if (typeof option === "string") return option; + if (!option || typeof option !== "object") return fallbackId; + const optionRecord = option as Record; + return ( + (typeof optionRecord.label === "string" && optionRecord.label) || + (typeof optionRecord.name === "string" && optionRecord.name) || + (typeof optionRecord.description === "string" && optionRecord.description) || + fallbackId + ); +} + +function getConfigChoices(config: AcpSessionConfigOption): ResolvedOption[] { + const options = config.options ?? []; + const choices: ResolvedOption[] = []; + for (const option of options) { + const id = resolveOptionId(option); + if (!id) continue; + choices.push({ + id, + label: resolveOptionLabel(option, id), + }); + } + return choices; +} + +function getConfigLabel(config: AcpSessionConfigOption): string { + return config.name || config.id; +} + +function findModeLabel(mode: AcpSessionMode, t: (key: string) => string): string { + if (mode.name?.trim()) return mode.name; + const slug = normalizeModeSlug(mode.id); + switch (slug) { + case "ask": + return t("acp.modeAsk"); + case "plan": + return t("acp.modePlan"); + case "code": + return t("acp.modeCode"); + case "autopilot": + return t("acp.modeAutopilot"); + case "agent": + return t("acp.modeAgent"); + case "edit": + return t("acp.modeEdit"); + default: + return mode.id; + } +} + +function isModelConfig(option: AcpSessionConfigOption): boolean { + if (normalizeSearch(option.category) === "model") return true; + const haystack = [option.id, option.name, option.description].map(normalizeSearch).join(" "); + return haystack.includes("model"); +} + +function isAgentConfig(option: AcpSessionConfigOption): boolean { + const haystack = [option.id, option.name, option.description, option.category] + .map(normalizeSearch) + .join(" "); + return haystack.includes("agent"); +} + +export function AcpSessionControls({ + disabled, + currentModeId, + availableModes, + configOptions, + selectedConfigOptions, + onSelectMode, + onSelectConfigOption, +}: AcpSessionControlsProps) { + const { t } = useTranslation(); + + const modeLabelMap = useMemo(() => { + const map = new Map(); + for (const mode of availableModes) { + map.set(mode.id, findModeLabel(mode, t)); + } + return map; + }, [availableModes, t]); + + const currentModeLabel = currentModeId + ? modeLabelMap.get(currentModeId) ?? currentModeId + : t("acp.unavailableFromAcp"); + + const modeDisabled = disabled || availableModes.length === 0; + + const { modelConfig, agentConfig, advancedConfigs } = useMemo(() => { + const model = configOptions.find((option) => isModelConfig(option)) ?? null; + const withoutModel = model + ? configOptions.filter((option) => option.id !== model.id) + : [...configOptions]; + const agent = withoutModel.find((option) => isAgentConfig(option)) ?? null; + const advanced = withoutModel.filter((option) => option.id !== agent?.id); + return { + modelConfig: model, + agentConfig: agent, + advancedConfigs: advanced, + }; + }, [configOptions]); + + const modelChoices = modelConfig ? getConfigChoices(modelConfig) : []; + const agentChoices = agentConfig ? getConfigChoices(agentConfig) : []; + const advancedSelectable = useMemo( + () => + advancedConfigs + .map((config) => ({ config, choices: getConfigChoices(config) })) + .filter((entry) => entry.choices.length > 0), + [advancedConfigs] + ); + + const modelUnavailable = !modelConfig || modelChoices.length === 0; + const agentUnavailable = !agentConfig || agentChoices.length === 0; + const advancedUnavailable = advancedSelectable.length === 0; + + const modelSelected = modelConfig + ? selectedConfigOptions[modelConfig.id] + : undefined; + const modelSelectedLabel = + modelChoices.find((choice) => choice.id === modelSelected)?.label ?? + t("acp.unavailableFromAcp"); + + const agentSelected = agentConfig + ? selectedConfigOptions[agentConfig.id] + : undefined; + const agentSelectedLabel = + agentChoices.find((choice) => choice.id === agentSelected)?.label ?? + t("acp.unavailableFromAcp"); + + return ( + <> + + + + + + {availableModes.map((mode) => ( + onSelectMode(mode.id)} + className="text-xs" + > + {modeLabelMap.get(mode.id) ?? mode.id} + + ))} + + + + + + + + + {modelConfig && + modelChoices.map((choice) => ( + onSelectConfigOption(modelConfig.id, choice.id)} + className="text-xs" + > + {choice.label} + + ))} + + + + + + + + + {agentConfig && + agentChoices.map((choice) => ( + onSelectConfigOption(agentConfig.id, choice.id)} + className="text-xs" + > + {choice.label} + + ))} + + + + + + + + + {advancedSelectable.map(({ config, choices }) => { + const selectedOptionId = selectedConfigOptions[config.id]; + const selectedLabel = + choices.find((choice) => choice.id === selectedOptionId)?.label ?? null; + return ( + + + {selectedLabel + ? `${getConfigLabel(config)}: ${selectedLabel}` + : getConfigLabel(config)} + + + {choices.map((choice) => ( + onSelectConfigOption(config.id, choice.id)} + className="text-xs" + > + {choice.label} + + ))} + + + ); + })} + {advancedUnavailable && ( + + {t("acp.unavailableFromAcp")} + + )} + + + + ); +} diff --git a/apps/tauri/src/components/ActiveSessionView.tsx b/apps/tauri/src/components/ActiveSessionView.tsx index ea73869..8920f87 100644 --- a/apps/tauri/src/components/ActiveSessionView.tsx +++ b/apps/tauri/src/components/ActiveSessionView.tsx @@ -10,6 +10,7 @@ import { useAcpSession } from "@/hooks/useAcpSession"; import { useAcpLogs } from "@/hooks/useAcpLogs"; import { usePlanWorkflow } from "@/hooks/usePlanWorkflow"; import { ConnectionLogs } from "@/components/ConnectionLogs"; +import { AcpSessionControls } from "@/components/AcpSessionControls"; import type { SessionRecord, PlanPhase } from "@/types"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -70,10 +71,38 @@ export function ActiveSessionView({ const [chatCollapsed, setChatCollapsed] = useState(false); const [planCollapsed, setPlanCollapsed] = useState(false); const [initError, setInitError] = useState(null); + const { t } = useTranslation(); - const acp = useAcpSession(workspaceId, workspacePath, isConnected); + const acp = useAcpSession(workspaceId, workspacePath, session.id, isConnected); const acpLogs = useAcpLogs(workspaceId); + const getModeLabel = useCallback((modeId: string) => { + const mode = acp.availableModes.find((m) => m.id === modeId); + if (mode?.name?.trim()) return mode.name; + + const slug = (modeId.split("#").pop() ?? modeId.split("/").pop() ?? modeId).toLowerCase(); + switch (slug) { + case "ask": + return t("acp.modeAsk"); + case "plan": + return t("acp.modePlan"); + case "code": + return t("acp.modeCode"); + case "autopilot": + return t("acp.modeAutopilot"); + case "agent": + return t("acp.modeAgent"); + case "edit": + return t("acp.modeEdit"); + default: + return modeId; + } + }, [acp.availableModes, t]); + + const handleAutoSwitchMode = useCallback((modeId: string) => { + acp.appendNotice(t("acp.autoSwitchNotice", { mode: getModeLabel(modeId) })); + }, [acp.appendNotice, getModeLabel, t]); + const plan = usePlanWorkflow({ workspaceId, activeSessionId: acp.activeAcpSessionId, @@ -86,6 +115,7 @@ export function ActiveSessionView({ availableModes: acp.availableModes, sendPrompt: acp.sendPrompt, setMode: acp.setMode, + onAutoSwitchMode: handleAutoSwitchMode, onPhaseChange: onPhaseChange ? (phase) => onPhaseChange(session.id, phase) : undefined, @@ -162,7 +192,6 @@ export function ActiveSessionView({ return 60; }, [session.id]); - const { t } = useTranslation(); const currentPhase = plan.phase ?? session.phase; const handleToggleChat = useCallback(() => { @@ -238,6 +267,19 @@ export function ActiveSessionView({ ))} + { + void acp.setMode(modeId, { origin: "user" }); + }} + onSelectConfigOption={(configId, optionId) => { + void acp.setConfigOption(configId, optionId); + }} + /> {isConnecting ? ( diff --git a/apps/tauri/src/hooks/useAcpSession.ts b/apps/tauri/src/hooks/useAcpSession.ts index fe45137..06e32ab 100644 --- a/apps/tauri/src/hooks/useAcpSession.ts +++ b/apps/tauri/src/hooks/useAcpSession.ts @@ -1,50 +1,139 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; import type { - AcpSessionInfo, AcpMessage, + AcpPreferences, + AcpSessionConfigOption, + AcpSessionInfo, + AcpSessionMode, } from "@/types/acp"; import { + addSystemNotice, + addUserMessage, sessionStore, subscribeSession, - updateSessionEntry, - addUserMessage, type SessionEntry, + updateSessionEntry, } from "@/lib/session-cache"; +interface SessionRecordPreferences { + acp_preferences_json: string; +} + interface UseAcpSessionReturn { isStreaming: boolean; errors: string[]; messages: AcpMessage[]; currentMode: string | null; - availableModes: string[]; + availableModes: AcpSessionMode[]; + availableConfigOptions: AcpSessionConfigOption[]; + selectedConfigOptions: Record; activeAcpSessionId: string | null; agentPlanFilePath: string | null; startSession: (acpSessionId?: string) => Promise; sendPrompt: (text: string) => Promise; - setMode: (mode: string) => Promise; + setMode: ( + mode: string, + options?: { origin?: "user" | "workflow" } + ) => Promise; + setConfigOption: (configId: string, optionId: string) => Promise; cancel: () => Promise; + appendNotice: (text: string) => void; clearErrors: () => void; clearMessages: () => void; } -function extractModes( - info: AcpSessionInfo, - cb: (modes: string[], current: string | null) => void -) { - if (info.modes) { - cb( - info.modes.availableModes.map((m) => m.id), - info.modes.currentModeId ?? null +function normalizeSelectedConfigOptions(value: unknown): Record { + if (!value || typeof value !== "object") return {}; + const raw = value as Record; + const normalized: Record = {}; + for (const [configId, selected] of Object.entries(raw)) { + if (typeof selected === "string") { + normalized[configId] = selected; + continue; + } + if (!selected || typeof selected !== "object") continue; + const selectedRecord = selected as Record; + const optionId = + (typeof selectedRecord.optionId === "string" && selectedRecord.optionId) || + (typeof selectedRecord.id === "string" && selectedRecord.id) || + (typeof selectedRecord.value === "string" && selectedRecord.value) || + null; + if (optionId) normalized[configId] = optionId; + } + return normalized; +} + +function extractSessionState( + info: AcpSessionInfo +): Pick< + SessionEntry, + "availableModes" | "currentMode" | "availableConfigOptions" | "selectedConfigOptions" +> { + const availableModes = info.modes?.availableModes ?? []; + const currentMode = info.modes?.currentModeId ?? null; + const availableConfigOptions = info.configOptions?.availableConfigOptions ?? []; + const selectedConfigOptions = normalizeSelectedConfigOptions( + info.configOptions?.selectedConfigOptions + ); + return { + availableModes, + currentMode, + availableConfigOptions, + selectedConfigOptions, + }; +} + +function parsePreferencesJson(raw: string | null | undefined): AcpPreferences { + if (!raw?.trim()) { + return { modeId: null, selectedConfigOptions: {} }; + } + try { + const parsed = JSON.parse(raw) as Partial; + const modeId = typeof parsed.modeId === "string" ? parsed.modeId : null; + const selectedConfigOptions = normalizeSelectedConfigOptions( + parsed.selectedConfigOptions ); + return { modeId, selectedConfigOptions }; + } catch { + return { modeId: null, selectedConfigOptions: {} }; } } +function isEmptyPreferences(prefs: AcpPreferences): boolean { + return !prefs.modeId && Object.keys(prefs.selectedConfigOptions).length === 0; +} + +function optionIdFromValue(value: unknown): string | null { + if (typeof value === "string") return value; + if (!value || typeof value !== "object") return null; + const record = value as Record; + return ( + (typeof record.optionId === "string" && record.optionId) || + (typeof record.id === "string" && record.id) || + (typeof record.value === "string" && record.value) || + null + ); +} + +function hasConfigOption( + configOptions: AcpSessionConfigOption[], + configId: string, + optionId: string +): boolean { + const config = configOptions.find((item) => item.id === configId); + if (!config) return false; + const options = config.options ?? []; + return options.some((option) => optionIdFromValue(option) === optionId); +} + const EMPTY_SESSION: SessionEntry = { messages: [], activeAcpSessionId: null, currentMode: null, availableModes: [], + availableConfigOptions: [], + selectedConfigOptions: {}, agentPlanFilePath: null, isStreaming: false, }; @@ -52,6 +141,7 @@ const EMPTY_SESSION: SessionEntry = { export function useAcpSession( workspaceId: string, workspacePath: string, + localSessionId: string, isConnected: boolean ): UseAcpSessionReturn { const cached = sessionStore.get(workspaceId); @@ -59,15 +149,30 @@ export function useAcpSession( const [messages, setMessages] = useState(cached?.messages ?? []); const [isStreaming, setIsStreaming] = useState(cached?.isStreaming ?? false); const [currentMode, setCurrentMode] = useState(cached?.currentMode ?? null); - const [availableModes, setAvailableModes] = useState(cached?.availableModes ?? []); - const [activeAcpSessionId, setActiveAcpSessionId] = useState(cached?.activeAcpSessionId ?? null); - const [agentPlanFilePath, setAgentPlanFilePath] = useState(cached?.agentPlanFilePath ?? null); + const [availableModes, setAvailableModes] = useState( + cached?.availableModes ?? [] + ); + const [availableConfigOptions, setAvailableConfigOptions] = useState< + AcpSessionConfigOption[] + >(cached?.availableConfigOptions ?? []); + const [selectedConfigOptions, setSelectedConfigOptions] = useState< + Record + >(cached?.selectedConfigOptions ?? {}); + const [activeAcpSessionId, setActiveAcpSessionId] = useState( + cached?.activeAcpSessionId ?? null + ); + const [agentPlanFilePath, setAgentPlanFilePath] = useState( + cached?.agentPlanFilePath ?? null + ); const [errors, setErrors] = useState([]); const activeAcpSessionIdRef = useRef(cached?.activeAcpSessionId ?? null); - const workspaceIdRef = useRef(workspaceId); - workspaceIdRef.current = workspaceId; + const localSessionIdRef = useRef(localSessionId); + localSessionIdRef.current = localSessionId; + const workspacePathRef = useRef(workspacePath); + workspacePathRef.current = workspacePath; const idleTimerRef = useRef | null>(null); + const persistQueueRef = useRef>(Promise.resolve()); useEffect(() => { const entry = sessionStore.get(workspaceId); @@ -76,20 +181,24 @@ export function useAcpSession( setIsStreaming(entry.isStreaming); setCurrentMode(entry.currentMode); setAvailableModes(entry.availableModes); + setAvailableConfigOptions(entry.availableConfigOptions); + setSelectedConfigOptions(entry.selectedConfigOptions); setActiveAcpSessionId(entry.activeAcpSessionId); setAgentPlanFilePath(entry.agentPlanFilePath); activeAcpSessionIdRef.current = entry.activeAcpSessionId; } - const unsubscribe = subscribeSession(workspaceId, (e) => { - setMessages(e.messages); - setCurrentMode(e.currentMode); - setAvailableModes(e.availableModes); - setActiveAcpSessionId(e.activeAcpSessionId); - setAgentPlanFilePath(e.agentPlanFilePath); - activeAcpSessionIdRef.current = e.activeAcpSessionId; + const unsubscribe = subscribeSession(workspaceId, (entryUpdate) => { + setMessages(entryUpdate.messages); + setCurrentMode(entryUpdate.currentMode); + setAvailableModes(entryUpdate.availableModes); + setAvailableConfigOptions(entryUpdate.availableConfigOptions); + setSelectedConfigOptions(entryUpdate.selectedConfigOptions); + setActiveAcpSessionId(entryUpdate.activeAcpSessionId); + setAgentPlanFilePath(entryUpdate.agentPlanFilePath); + activeAcpSessionIdRef.current = entryUpdate.activeAcpSessionId; - if (e.isStreaming) { + if (entryUpdate.isStreaming) { setIsStreaming(true); if (idleTimerRef.current) clearTimeout(idleTimerRef.current); idleTimerRef.current = setTimeout(() => setIsStreaming(false), 800); @@ -111,6 +220,200 @@ export function useAcpSession( }; }, []); + const persistSessionAndWorkspace = useCallback( + (preferences: AcpPreferences) => { + const payload = JSON.stringify(preferences); + persistQueueRef.current = persistQueueRef.current.then(async () => { + try { + await invoke("session_update_acp_preferences", { + id: localSessionIdRef.current, + acpPreferencesJson: payload, + }); + } catch (e) { + setErrors((prev) => [...prev, String(e)]); + } + + try { + await invoke("workspace_acp_defaults_set", { + workspacePath: workspacePathRef.current, + acpPreferencesJson: payload, + }); + } catch (e) { + setErrors((prev) => [...prev, String(e)]); + } + }); + }, + [] + ); + + const persistSessionOnly = useCallback((preferences: AcpPreferences) => { + const payload = JSON.stringify(preferences); + persistQueueRef.current = persistQueueRef.current.then(async () => { + try { + await invoke("session_update_acp_preferences", { + id: localSessionIdRef.current, + acpPreferencesJson: payload, + }); + } catch (e) { + setErrors((prev) => [...prev, String(e)]); + } + }); + }, []); + + const setMode = useCallback( + async ( + mode: string, + options?: { origin?: "user" | "workflow" } + ): Promise => { + const sid = activeAcpSessionIdRef.current; + if (!sid) return false; + const existing = sessionStore.get(workspaceId); + if (existing?.currentMode === mode) return false; + + try { + await invoke("acp_set_mode", { + workspaceId, + sessionId: sid, + mode, + }); + updateSessionEntry(workspaceId, { currentMode: mode }); + + if ((options?.origin ?? "user") === "user") { + const updated = sessionStore.get(workspaceId); + if (updated) { + persistSessionAndWorkspace({ + modeId: mode, + selectedConfigOptions: { ...updated.selectedConfigOptions }, + }); + } + } + return true; + } catch (e) { + setErrors((prev) => [...prev, String(e)]); + return false; + } + }, + [workspaceId, persistSessionAndWorkspace] + ); + + const setConfigOption = useCallback( + async (configId: string, optionId: string): Promise => { + const sid = activeAcpSessionIdRef.current; + if (!sid) return false; + + const existing = sessionStore.get(workspaceId); + if (!existing) return false; + if (!hasConfigOption(existing.availableConfigOptions, configId, optionId)) { + return false; + } + if (existing.selectedConfigOptions[configId] === optionId) return false; + + try { + await invoke("acp_set_config_option", { + workspaceId, + sessionId: sid, + configId, + optionId, + }); + const nextSelected = { + ...existing.selectedConfigOptions, + [configId]: optionId, + }; + updateSessionEntry(workspaceId, { selectedConfigOptions: nextSelected }); + persistSessionAndWorkspace({ + modeId: existing.currentMode, + selectedConfigOptions: nextSelected, + }); + return true; + } catch (e) { + setErrors((prev) => [...prev, String(e)]); + return false; + } + }, + [workspaceId, persistSessionAndWorkspace] + ); + + const applyInitialPreferences = useCallback( + async (acpSessionId: string) => { + const sessionRecord = await invoke("session_get", { + id: localSessionIdRef.current, + }); + const sessionPreferences = parsePreferencesJson(sessionRecord.acp_preferences_json); + + let source: "session" | "workspace" = "session"; + let preferencesToApply = sessionPreferences; + if (isEmptyPreferences(preferencesToApply)) { + const workspaceDefaults = await invoke( + "workspace_acp_defaults_get", + { workspacePath: workspacePathRef.current } + ); + const parsedWorkspace = parsePreferencesJson(workspaceDefaults ?? "{}"); + if (!isEmptyPreferences(parsedWorkspace)) { + source = "workspace"; + preferencesToApply = parsedWorkspace; + } + } + + if (isEmptyPreferences(preferencesToApply)) return; + + const currentEntry = sessionStore.get(workspaceId); + if (!currentEntry) return; + + const applied: AcpPreferences = { + modeId: null, + selectedConfigOptions: {}, + }; + + if ( + preferencesToApply.modeId && + currentEntry.availableModes.some((mode) => mode.id === preferencesToApply.modeId) + ) { + const changed = await setMode(preferencesToApply.modeId, { origin: "workflow" }); + const modeAfter = changed + ? preferencesToApply.modeId + : sessionStore.get(workspaceId)?.currentMode ?? currentEntry.currentMode; + applied.modeId = modeAfter; + } else { + applied.modeId = currentEntry.currentMode; + } + + for (const [configId, optionId] of Object.entries( + preferencesToApply.selectedConfigOptions + )) { + const latest = sessionStore.get(workspaceId); + if (!latest) break; + if (!hasConfigOption(latest.availableConfigOptions, configId, optionId)) continue; + try { + await invoke("acp_set_config_option", { + workspaceId, + sessionId: acpSessionId, + configId, + optionId, + }); + const nextSelected = { + ...(sessionStore.get(workspaceId)?.selectedConfigOptions ?? {}), + [configId]: optionId, + }; + updateSessionEntry(workspaceId, { selectedConfigOptions: nextSelected }); + applied.selectedConfigOptions[configId] = optionId; + } catch { + // Ignore stale/invalid config values without failing session startup. + } + } + + if (source === "workspace") { + persistSessionOnly(applied); + } else { + const expected = JSON.stringify(preferencesToApply); + const normalizedApplied = JSON.stringify(applied); + if (expected !== normalizedApplied) { + persistSessionOnly(applied); + } + } + }, + [workspaceId, setMode, persistSessionOnly] + ); + const startSession = useCallback( async (existingAcpSessionId?: string): Promise => { if (!isConnected) throw new Error("Not connected to ACP"); @@ -118,6 +421,8 @@ export function useAcpSession( setErrors([]); let acpId: string; + let info: AcpSessionInfo | null = null; + if (existingAcpSessionId) { const previousEntry = sessionStore.get(workspaceId); @@ -130,24 +435,29 @@ export function useAcpSession( setIsStreaming(false); setCurrentMode(null); setAvailableModes([]); + setAvailableConfigOptions([]); + setSelectedConfigOptions({}); setActiveAcpSessionId(null); setAgentPlanFilePath(null); try { - const info = await invoke("acp_load_session", { + info = await invoke("acp_load_session", { workspaceId, sessionId: existingAcpSessionId, - cwd: workspacePath, + cwd: workspacePathRef.current, }); acpId = info.sessionId; - extractModes(info, (modes, current) => { - updateSessionEntry(workspaceId, { availableModes: modes, currentMode: current }); - }); } catch (e) { if (String(e).includes("already loaded")) { acpId = existingAcpSessionId; - if (previousEntry && previousEntry.messages.length > 0) { - updateSessionEntry(workspaceId, { messages: previousEntry.messages }); + if (previousEntry) { + updateSessionEntry(workspaceId, { + messages: previousEntry.messages, + currentMode: previousEntry.currentMode, + availableModes: previousEntry.availableModes, + availableConfigOptions: previousEntry.availableConfigOptions, + selectedConfigOptions: previousEntry.selectedConfigOptions, + }); } } else { throw e; @@ -160,24 +470,37 @@ export function useAcpSession( setIsStreaming(false); setCurrentMode(null); setAvailableModes([]); + setAvailableConfigOptions([]); + setSelectedConfigOptions({}); setActiveAcpSessionId(null); setAgentPlanFilePath(null); - const info = await invoke("acp_new_session", { + info = await invoke("acp_new_session", { workspaceId, - cwd: workspacePath, + cwd: workspacePathRef.current, }); acpId = info.sessionId; - extractModes(info, (modes, current) => { - updateSessionEntry(workspaceId, { availableModes: modes, currentMode: current }); - }); } activeAcpSessionIdRef.current = acpId; - updateSessionEntry(workspaceId, { activeAcpSessionId: acpId }); + if (info) { + updateSessionEntry(workspaceId, { + activeAcpSessionId: acpId, + ...extractSessionState(info), + }); + } else { + updateSessionEntry(workspaceId, { activeAcpSessionId: acpId }); + } + + try { + await applyInitialPreferences(acpId); + } catch (e) { + setErrors((prev) => [...prev, String(e)]); + } + return acpId; }, - [isConnected, workspaceId, workspacePath] + [isConnected, workspaceId, applyInitialPreferences] ); const sendPrompt = useCallback( @@ -200,7 +523,7 @@ export function useAcpSession( await invoke("acp_load_session", { workspaceId, sessionId: sid, - cwd: workspacePath, + cwd: workspacePathRef.current, }); await invoke("acp_send_prompt", { workspaceId, @@ -216,24 +539,6 @@ export function useAcpSession( updateSessionEntry(workspaceId, { isStreaming: false }); } }, - [workspaceId, workspacePath] - ); - - const setMode = useCallback( - async (mode: string) => { - const sid = activeAcpSessionIdRef.current; - if (!sid) return; - try { - await invoke("acp_set_mode", { - workspaceId, - sessionId: sid, - mode, - }); - updateSessionEntry(workspaceId, { currentMode: mode }); - } catch (e) { - setErrors((prev) => [...prev, String(e)]); - } - }, [workspaceId] ); @@ -251,6 +556,13 @@ export function useAcpSession( } }, [workspaceId]); + const appendNotice = useCallback( + (text: string) => { + addSystemNotice(workspaceId, text); + }, + [workspaceId] + ); + const clearErrors = useCallback(() => setErrors([]), []); const clearMessages = useCallback(() => { updateSessionEntry(workspaceId, { messages: [] }); @@ -262,12 +574,16 @@ export function useAcpSession( messages, currentMode, availableModes, + availableConfigOptions, + selectedConfigOptions, activeAcpSessionId, agentPlanFilePath, startSession, sendPrompt, setMode, + setConfigOption, cancel, + appendNotice, clearErrors, clearMessages, }; diff --git a/apps/tauri/src/hooks/usePlanWorkflow.ts b/apps/tauri/src/hooks/usePlanWorkflow.ts index 0e9f96e..0d5a4ec 100644 --- a/apps/tauri/src/hooks/usePlanWorkflow.ts +++ b/apps/tauri/src/hooks/usePlanWorkflow.ts @@ -1,6 +1,7 @@ import { useState, useCallback, useRef, useEffect } from "react"; import { invoke } from "@tauri-apps/api/core"; import type { PlanPhase } from "@/types"; +import type { AcpSessionMode } from "@/types/acp"; interface UsePlanWorkflowReturn { phase: PlanPhase; @@ -20,9 +21,13 @@ interface UsePlanWorkflowParams { sessionPlanFilePath: string | null; agentPlanFilePath: string | null; isStreaming: boolean; - availableModes: string[]; + availableModes: AcpSessionMode[]; sendPrompt: (text: string) => Promise; - setMode: (mode: string) => Promise; + setMode: ( + mode: string, + options?: { origin?: "user" | "workflow" } + ) => Promise; + onAutoSwitchMode?: (modeId: string) => void; onPhaseChange?: (phase: PlanPhase) => void; } @@ -37,6 +42,7 @@ export function usePlanWorkflow({ availableModes, sendPrompt, setMode, + onAutoSwitchMode, onPhaseChange, }: UsePlanWorkflowParams): UsePlanWorkflowReturn { const [phase, setPhaseRaw] = useState(initialPhase ?? "idle"); @@ -85,7 +91,10 @@ export function usePlanWorkflow({ async (sessionId: string, prompt: string) => { const planMode = findModeBySlug(availableModesRef.current, "plan"); if (planMode) { - await setModeRef.current(planMode); + const switched = await setModeRef.current(planMode, { origin: "workflow" }); + if (switched) { + onAutoSwitchMode?.(planMode); + } } setPhase("planning"); @@ -98,7 +107,7 @@ export function usePlanWorkflow({ await sendPromptRef.current(prompt); }, - [workspaceId, localSessionId] + [workspaceId, localSessionId, onAutoSwitchMode] ); const approvePlan = useCallback( @@ -107,7 +116,10 @@ export function usePlanWorkflow({ const agentMode = findModeBySlug(availableModesRef.current, "agent"); if (agentMode) { - await setModeRef.current(agentMode); + const switched = await setModeRef.current(agentMode, { origin: "workflow" }); + if (switched) { + onAutoSwitchMode?.(agentMode); + } } setPhase("executing"); @@ -124,7 +136,7 @@ export function usePlanWorkflow({ await sendPromptRef.current(prompt); }, - [workspaceId, acpSessionId, activeSessionId, localSessionId] + [workspaceId, acpSessionId, activeSessionId, localSessionId, onAutoSwitchMode] ); const requestChanges = useCallback( @@ -155,13 +167,12 @@ export function usePlanWorkflow({ }; } -function findModeBySlug( - availableModes: string[], - slug: string -): string | null { +function findModeBySlug(availableModes: AcpSessionMode[], slug: string): string | null { + const slugLower = slug.toLowerCase(); return ( - availableModes.find((id) => id.endsWith(`#${slug}`)) ?? - availableModes.find((id) => id.includes(slug)) ?? + availableModes.find((mode) => mode.id.endsWith(`#${slugLower}`))?.id ?? + availableModes.find((mode) => mode.id.toLowerCase().includes(slugLower))?.id ?? + availableModes.find((mode) => mode.name?.toLowerCase().includes(slugLower))?.id ?? null ); } diff --git a/apps/tauri/src/lib/session-cache.ts b/apps/tauri/src/lib/session-cache.ts index 06464c3..872a23f 100644 --- a/apps/tauri/src/lib/session-cache.ts +++ b/apps/tauri/src/lib/session-cache.ts @@ -1,4 +1,9 @@ -import type { AcpMessage, AcpSessionUpdate } from "@/types/acp"; +import type { + AcpMessage, + AcpSessionConfigOption, + AcpSessionMode, + AcpSessionUpdate, +} from "@/types/acp"; import type { AcpConnectionStatus } from "@/hooks/useAcpConnection"; interface ConnectionEntry { @@ -10,7 +15,9 @@ export interface SessionEntry { messages: AcpMessage[]; activeAcpSessionId: string | null; currentMode: string | null; - availableModes: string[]; + availableModes: AcpSessionMode[]; + availableConfigOptions: AcpSessionConfigOption[]; + selectedConfigOptions: Record; agentPlanFilePath: string | null; isStreaming: boolean; } @@ -52,11 +59,84 @@ function nextMsgId() { return `msg-${++msgCounter}-${Date.now()}`; } +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" ? (value as Record) : null; +} + +function normalizeSelectedConfigOptions(value: unknown): Record { + if (!value || typeof value !== "object") return {}; + const raw = value as Record; + const normalized: Record = {}; + for (const [configId, selected] of Object.entries(raw)) { + if (typeof selected === "string") { + normalized[configId] = selected; + continue; + } + const selectedRecord = asRecord(selected); + if (!selectedRecord) continue; + const optionId = + (typeof selectedRecord.optionId === "string" && selectedRecord.optionId) || + (typeof selectedRecord.id === "string" && selectedRecord.id) || + (typeof selectedRecord.value === "string" && selectedRecord.value) || + null; + if (optionId) normalized[configId] = optionId; + } + return normalized; +} + +function readConfigState(payload: Record): { + availableConfigOptions?: AcpSessionConfigOption[]; + selectedConfigOptions?: Record; +} { + const configOptionsContainer = asRecord(payload.configOptions); + const container = configOptionsContainer ?? payload; + const containerRecord = asRecord(container); + if (!containerRecord) return {}; + + const availableRaw = containerRecord.availableConfigOptions; + const selectedRaw = containerRecord.selectedConfigOptions; + + const availableConfigOptions = Array.isArray(availableRaw) + ? (availableRaw as AcpSessionConfigOption[]) + : undefined; + const selectedConfigOptions = selectedRaw + ? normalizeSelectedConfigOptions(selectedRaw) + : undefined; + + return { availableConfigOptions, selectedConfigOptions }; +} + +function readModeState(payload: Record): { + availableModes?: AcpSessionMode[]; + currentMode?: string | null; +} { + const modesContainer = asRecord(payload.modes); + const availableRaw = modesContainer?.availableModes; + const availableModes = Array.isArray(availableRaw) + ? (availableRaw as AcpSessionMode[]) + : undefined; + + const currentMode = + (typeof payload.currentModeId === "string" && payload.currentModeId) || + (typeof payload.modeId === "string" && payload.modeId) || + (typeof modesContainer?.currentModeId === "string" && modesContainer.currentModeId) || + undefined; + + return { availableModes, currentMode: currentMode ?? null }; +} + function processSessionUpdate(entry: SessionEntry, update: AcpSessionUpdate): SessionEntry { const { updateType, payload } = update; const p = payload as Record; const msgs = [...entry.messages]; - let { isStreaming, agentPlanFilePath, currentMode } = entry; + let { + isStreaming, + agentPlanFilePath, + currentMode, + availableModes, + availableConfigOptions, + selectedConfigOptions, + } = entry; switch (updateType) { case "agent_message_chunk": { @@ -64,13 +144,24 @@ function processSessionUpdate(entry: SessionEntry, update: AcpSessionUpdate): Se const text = content?.type === "text" ? (content.text as string) : ""; if (!text) break; if (/^(Warning:|Info:|🔬|Experimental)/.test(text)) { - msgs.push({ id: nextMsgId(), role: "assistant", type: "notice", content: text, timestamp: new Date() }); + msgs.push({ + id: nextMsgId(), + role: "assistant", + type: "notice", + content: text, + timestamp: new Date(), + }); } else { const last = msgs[msgs.length - 1]; if (last?.role === "assistant" && !last.type) { msgs[msgs.length - 1] = { ...last, content: last.content + text }; } else { - msgs.push({ id: nextMsgId(), role: "assistant", content: text, timestamp: new Date() }); + msgs.push({ + id: nextMsgId(), + role: "assistant", + content: text, + timestamp: new Date(), + }); } } isStreaming = true; @@ -84,7 +175,13 @@ function processSessionUpdate(entry: SessionEntry, update: AcpSessionUpdate): Se if (last?.role === "assistant" && last.type === "thinking") { msgs[msgs.length - 1] = { ...last, content: last.content + text }; } else { - msgs.push({ id: nextMsgId(), role: "assistant", type: "thinking", content: text, timestamp: new Date() }); + msgs.push({ + id: nextMsgId(), + role: "assistant", + type: "thinking", + content: text, + timestamp: new Date(), + }); } isStreaming = true; break; @@ -105,7 +202,11 @@ function processSessionUpdate(entry: SessionEntry, update: AcpSessionUpdate): Se }); const locations = p.locations as Array<{ path: string }> | undefined; const rawInput = p.rawInput as Record | undefined; - const filePath = locations?.[0]?.path || (rawInput?.path as string) || (rawInput?.file_path as string) || ""; + const filePath = + locations?.[0]?.path || + (rawInput?.path as string) || + (rawInput?.file_path as string) || + ""; if (filePath.endsWith("/plan.md")) agentPlanFilePath = filePath; isStreaming = true; break; @@ -117,7 +218,11 @@ function processSessionUpdate(entry: SessionEntry, update: AcpSessionUpdate): Se if (!summary) break; const idx = msgs.findIndex((m) => m.toolCallId === (p.toolCallId as string)); if (idx !== -1) { - msgs[idx] = { ...msgs[idx], content: `${msgs[idx].toolTitle || "Tool"}: ${summary}`, toolStatus: "completed" }; + msgs[idx] = { + ...msgs[idx], + content: `${msgs[idx].toolTitle || "Tool"}: ${summary}`, + toolStatus: "completed", + }; } break; } @@ -125,8 +230,15 @@ function processSessionUpdate(entry: SessionEntry, update: AcpSessionUpdate): Se const content = p.content; let text = ""; if (Array.isArray(content)) { - text = content.filter((c: Record) => c?.type === "text").map((c: Record) => (c.text as string) ?? "").join(""); - } else if (typeof content === "object" && content !== null && (content as Record).type === "text") { + text = content + .filter((c: Record) => c?.type === "text") + .map((c: Record) => (c.text as string) ?? "") + .join(""); + } else if ( + typeof content === "object" && + content !== null && + (content as Record).type === "text" + ) { text = (content as Record).text as string; } if (!text) break; @@ -138,12 +250,65 @@ function processSessionUpdate(entry: SessionEntry, update: AcpSessionUpdate): Se } break; } - case "current_mode_update": - currentMode = p.currentModeId as string; + case "current_mode_update": { + currentMode = + (typeof p.currentModeId === "string" && p.currentModeId) || + (typeof p.modeId === "string" && p.modeId) || + currentMode; break; + } + case "config_option_update": + case "config_options_update": { + const state = readConfigState(p); + if (state.availableConfigOptions) availableConfigOptions = state.availableConfigOptions; + if (state.selectedConfigOptions) { + selectedConfigOptions = { ...selectedConfigOptions, ...state.selectedConfigOptions }; + } + const singleConfigId = + (typeof p.configId === "string" && p.configId) || + (typeof p.id === "string" && p.id) || + null; + const singleOptionId = + (typeof p.optionId === "string" && p.optionId) || + (typeof p.value === "string" && p.value) || + null; + if (singleConfigId && singleOptionId) { + selectedConfigOptions = { + ...selectedConfigOptions, + [singleConfigId]: singleOptionId, + }; + } + break; + } + case "session_info_update": { + const modeState = readModeState(p); + if (modeState.availableModes) availableModes = modeState.availableModes; + if (modeState.currentMode !== undefined) currentMode = modeState.currentMode; + + const configState = readConfigState(p); + if (configState.availableConfigOptions) { + availableConfigOptions = configState.availableConfigOptions; + } + if (configState.selectedConfigOptions) { + selectedConfigOptions = { + ...selectedConfigOptions, + ...configState.selectedConfigOptions, + }; + } + break; + } } - return { ...entry, messages: msgs, isStreaming, agentPlanFilePath, currentMode }; + return { + ...entry, + messages: msgs, + isStreaming, + agentPlanFilePath, + currentMode, + availableModes, + availableConfigOptions, + selectedConfigOptions, + }; } type SessionSubscriber = (entry: SessionEntry) => void; @@ -156,15 +321,18 @@ function resetStreamingTimer(workspaceId: string) { const existing = streamingTimers.get(workspaceId); if (existing) clearTimeout(existing); - streamingTimers.set(workspaceId, setTimeout(() => { - streamingTimers.delete(workspaceId); - const entry = sessions.get(workspaceId); - if (entry?.isStreaming) { - const updated = { ...entry, isStreaming: false }; - sessions.set(workspaceId, updated); - notifySubscribers(workspaceId, updated); - } - }, STREAMING_TIMEOUT_MS)); + streamingTimers.set( + workspaceId, + setTimeout(() => { + streamingTimers.delete(workspaceId); + const entry = sessions.get(workspaceId); + if (entry?.isStreaming) { + const updated = { ...entry, isStreaming: false }; + sessions.set(workspaceId, updated); + notifySubscribers(workspaceId, updated); + } + }, STREAMING_TIMEOUT_MS) + ); } function clearStreamingTimer(workspaceId: string) { @@ -186,25 +354,28 @@ let listenerSetup = false; function ensureGlobalListener() { if (listenerSetup) return; - window.__TAURI__.event.listen("acp:session-update", (event: { payload: AcpSessionUpdate }) => { - const update = event.payload; - const { workspaceId, sessionId } = update; - const entry = sessions.get(workspaceId); - if (!entry) return; - if (entry.activeAcpSessionId && sessionId !== entry.activeAcpSessionId) return; - - const newEntry = processSessionUpdate(entry, update); - sessions.set(workspaceId, newEntry); - notifySubscribers(workspaceId, newEntry); - - if (newEntry.isStreaming) { - resetStreamingTimer(workspaceId); - } else { - clearStreamingTimer(workspaceId); - } - }).then(() => { - listenerSetup = true; - }).catch(console.error); + window.__TAURI__.event + .listen("acp:session-update", (event: { payload: AcpSessionUpdate }) => { + const update = event.payload; + const { workspaceId, sessionId } = update; + const entry = sessions.get(workspaceId); + if (!entry) return; + if (entry.activeAcpSessionId && sessionId !== entry.activeAcpSessionId) return; + + const newEntry = processSessionUpdate(entry, update); + sessions.set(workspaceId, newEntry); + notifySubscribers(workspaceId, newEntry); + + if (newEntry.isStreaming) { + resetStreamingTimer(workspaceId); + } else { + clearStreamingTimer(workspaceId); + } + }) + .then(() => { + listenerSetup = true; + }) + .catch(console.error); } export function subscribeSession(workspaceId: string, cb: SessionSubscriber): () => void { @@ -234,10 +405,27 @@ export function addUserMessage(workspaceId: string, text: string) { if (!entry) return; const newEntry = { ...entry, - messages: [...entry.messages, { id: nextMsgId(), role: "user" as const, content: text, timestamp: new Date() }], + messages: [ + ...entry.messages, + { id: nextMsgId(), role: "user" as const, content: text, timestamp: new Date() }, + ], isStreaming: true, }; sessions.set(workspaceId, newEntry); notifySubscribers(workspaceId, newEntry); resetStreamingTimer(workspaceId); } + +export function addSystemNotice(workspaceId: string, text: string) { + const entry = sessions.get(workspaceId); + if (!entry || !text.trim()) return; + const newEntry = { + ...entry, + messages: [ + ...entry.messages, + { id: nextMsgId(), role: "assistant" as const, type: "notice" as const, content: text, timestamp: new Date() }, + ], + }; + sessions.set(workspaceId, newEntry); + notifySubscribers(workspaceId, newEntry); +} diff --git a/apps/tauri/src/locales/en.json b/apps/tauri/src/locales/en.json index 767a9b6..dd30ff1 100644 --- a/apps/tauri/src/locales/en.json +++ b/apps/tauri/src/locales/en.json @@ -102,7 +102,14 @@ "modeCode": "Code", "modeAutopilot": "Autopilot", "modeAgent": "Agent", - "modeEdit": "Edit" + "modeEdit": "Edit", + "modeLabel": "Mode", + "modelLabel": "Model", + "agentLabel": "Agent", + "advancedLabel": "Advanced", + "advancedAvailable": "{{count}} available", + "unavailableFromAcp": "Unavailable from ACP", + "autoSwitchNotice": "Auto-switched mode to {{mode}}." }, "whisper": { "recordTooltip": "Voice to text", diff --git a/apps/tauri/src/locales/pt-BR.json b/apps/tauri/src/locales/pt-BR.json index 1a1dd01..8d09d6c 100644 --- a/apps/tauri/src/locales/pt-BR.json +++ b/apps/tauri/src/locales/pt-BR.json @@ -102,7 +102,14 @@ "modeCode": "Código", "modeAutopilot": "Autopilot", "modeAgent": "Agente", - "modeEdit": "Editar" + "modeEdit": "Editar", + "modeLabel": "Modo", + "modelLabel": "Modelo", + "agentLabel": "Agente", + "advancedLabel": "Avançado", + "advancedAvailable": "{{count}} disponíveis", + "unavailableFromAcp": "Indisponível no ACP", + "autoSwitchNotice": "Modo alterado automaticamente para {{mode}}." }, "whisper": { "recordTooltip": "Voz para texto", @@ -246,4 +253,4 @@ "terminal": { "done": "feito" } -} \ No newline at end of file +} diff --git a/apps/tauri/src/types/acp.ts b/apps/tauri/src/types/acp.ts index 8e39304..5a18f00 100644 --- a/apps/tauri/src/types/acp.ts +++ b/apps/tauri/src/types/acp.ts @@ -9,9 +9,38 @@ export interface AcpSessionModeState { currentModeId?: string; } +export interface AcpSessionConfigOptionValue { + id?: string; + optionId?: string; + value?: string; + name?: string; + label?: string; + description?: string; +} + +export interface AcpSessionConfigOption { + id: string; + name?: string; + description?: string; + category?: string; + type?: string; + options?: Array; +} + +export interface AcpSessionConfigOptionsState { + availableConfigOptions: AcpSessionConfigOption[]; + selectedConfigOptions?: Record; +} + export interface AcpSessionInfo { sessionId: string; modes?: AcpSessionModeState; + configOptions?: AcpSessionConfigOptionsState; +} + +export interface AcpPreferences { + modeId: string | null; + selectedConfigOptions: Record; } export interface AcpSessionSummary { diff --git a/apps/tauri/src/types/index.ts b/apps/tauri/src/types/index.ts index 3d86ded..6676b39 100644 --- a/apps/tauri/src/types/index.ts +++ b/apps/tauri/src/types/index.ts @@ -57,6 +57,7 @@ export interface SessionRecord { id: string; workspace_path: string; acp_session_id: string | null; + acp_preferences_json: string; name: string; initial_prompt: string; plan_markdown: string;