diff --git a/crates/acp/src/agents.rs b/crates/acp/src/agents.rs index 1aa61280..b711e90b 100644 --- a/crates/acp/src/agents.rs +++ b/crates/acp/src/agents.rs @@ -25,6 +25,13 @@ pub struct AcpAgentDef { /// Whether `command` resolves on PATH. Only filled in by `list_agents`. #[serde(default)] pub available: bool, + /// Whether the resolved launcher is the agent's own installed binary + /// rather than the `npx` download fallback. `available` alone says only + /// that *something* can be spawned: with Node present every npm-published + /// agent is "available", which is not what a caller asking "is this agent + /// installed?" means. + #[serde(default)] + pub local: bool, } /// One way to launch an agent. Presets list several, local binary first and @@ -154,6 +161,7 @@ impl Preset { .find(|launcher| which::which(launcher.command).is_ok()); let available = found.is_some(); let launcher = found.unwrap_or_else(|| self.launchers.last().expect("preset launcher")); + let local = available && launcher.command != "npx"; AcpAgentDef { id: self.id.to_string(), @@ -166,6 +174,7 @@ impl Preset { .map(|(k, v)| (k.to_string(), v.to_string())) .collect(), available, + local, } } } @@ -217,6 +226,7 @@ fn user_agents() -> Vec { id: entry.id.unwrap_or_else(|| slug(&name)), name, available: which::which(&entry.command).is_ok(), + local: which::which(&entry.command).is_ok(), command: entry.command, args: entry.args, env: entry.env, @@ -245,3 +255,76 @@ pub fn list_agents() -> Vec { pub fn find_preset(id: &str) -> Option { list_agents().into_iter().find(|a| a.id == id) } + +/// The npm package a preset would otherwise download on every run, read off +/// its `npx` launcher so there is only one place naming a package. `None` for +/// an agent that has no npm distribution — those can only be installed by hand. +fn npm_package(id: &str) -> Option<&'static str> { + let preset = PRESETS.iter().find(|p| p.id == id)?; + let npx = preset.launchers.iter().find(|l| l.command == "npx")?; + let package = npx.args.iter().find(|a| !a.starts_with('-'))?; + Some(package) +} + +/// Install a preset globally with npm, so it resolves on PATH from then on. +/// +/// The `npx` fallback launcher means an agent can run without this, but only +/// by re-downloading the package on every spawn — and only when Node is +/// installed at all. This is the one-off that makes the agent local. +pub fn install_preset(id: &str) -> Result { + let package = npm_package(id).ok_or_else(|| format!("{id} has no npm package to install"))?; + let npm = which::which("npm") + .map_err(|_| "npm was not found on PATH — install Node.js first.".to_string())?; + + log::info!("acp: installing {id} via npm install -g {package}"); + let output = std::process::Command::new(npm) + .args(["install", "-g", package]) + .output() + .map_err(|e| format!("could not run npm: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let message = stderr.trim(); + return Err(if message.is_empty() { + format!("npm install -g {package} failed") + } else { + message.to_string() + }); + } + + let def = find_preset(id).ok_or_else(|| format!("{id} is not a known agent"))?; + log::info!( + "acp: installed {id}: command={} local={} available={}", + def.command, + def.local, + def.available + ); + Ok(def) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn npm_package_comes_from_the_npx_launcher() { + assert_eq!(npm_package("keke"), Some("@milisp/keke@latest")); + // No npx launcher, so nothing to install for us. + assert_eq!(npm_package("kiro"), None); + assert_eq!(npm_package("nope"), None); + } + + #[test] + fn npx_fallback_is_not_a_local_install() { + let def = AcpAgentDef { + id: "keke".into(), + name: "Keke".into(), + command: "npx".into(), + args: vec![], + env: Default::default(), + available: true, + local: false, + }; + assert!(def.available && !def.local); + } +} diff --git a/crates/acp/src/client.rs b/crates/acp/src/client.rs index 3fcde9c2..0392ad7b 100644 --- a/crates/acp/src/client.rs +++ b/crates/acp/src/client.rs @@ -25,6 +25,10 @@ pub struct AcpClient { pub agent_id: String, /// Display name of the agent, stored alongside persisted sessions. pub agent_name: String, + /// The bot this process was started for, when it was started from the Bot + /// tab. Every session it opens is tagged with it, which is what keeps a + /// bot's conversations out of the per-project session lists. + pub bot_id: Option, /// Sessions opened on this connection, keyed by ACP session id. One agent /// process can host several sessions at once. sessions: Arc>, @@ -50,6 +54,7 @@ impl AcpClient { connection_id: String, agent: &AcpAgentDef, cwd: Option<&str>, + bot_id: Option, sink: Arc, ) -> Result<(Arc, Value), String> { let mut cmd = Command::new(&agent.command); @@ -74,6 +79,7 @@ impl AcpClient { connection_id: connection_id.clone(), agent_id: agent.id.clone(), agent_name: agent.name.clone(), + bot_id, sessions: Arc::new(DashMap::new()), last_session: Mutex::new(None), replaying: Arc::new(DashMap::new()), @@ -159,6 +165,7 @@ impl AcpClient { &self.agent_id, Some(&self.agent_name), cwd, + self.bot_id.as_deref(), ) { log::warn!("acp: failed to record session: {e}"); } @@ -186,6 +193,7 @@ impl AcpClient { &self.agent_id, Some(&self.agent_name), cwd, + self.bot_id.as_deref(), ) { log::warn!("acp: failed to record session: {e}"); } diff --git a/crates/acp/src/lib.rs b/crates/acp/src/lib.rs index 976a318f..9d7f5caf 100644 --- a/crates/acp/src/lib.rs +++ b/crates/acp/src/lib.rs @@ -7,7 +7,7 @@ pub mod agents; pub mod client; pub mod state; -pub use agents::{AcpAgentDef, find_preset, list_agents}; +pub use agents::{AcpAgentDef, find_preset, install_preset, list_agents}; /// Persisted session list and transcripts, stored by the client as it runs. pub use codexia_db::acp_sessions::{ AcpSessionRecord, delete_session, get_updates, list_sessions, diff --git a/crates/acp/src/state.rs b/crates/acp/src/state.rs index 4ecdb92c..3975bd0c 100644 --- a/crates/acp/src/state.rs +++ b/crates/acp/src/state.rs @@ -42,6 +42,7 @@ impl AcpState { agent_id: &str, cwd: &str, custom: Option, + bot_id: Option, ) -> Result { let agent = match custom { Some(a) => a, @@ -50,7 +51,8 @@ impl AcpState { let connection_id = uuid::Uuid::new_v4().to_string(); let (client, initialize) = - AcpClient::spawn(connection_id.clone(), &agent, Some(cwd), self.sink.clone()).await?; + AcpClient::spawn(connection_id.clone(), &agent, Some(cwd), bot_id, self.sink.clone()) + .await?; self.connections.insert(connection_id.clone(), client.clone()); let (session, session_error) = match client.new_session(cwd).await { diff --git a/crates/acp/tests/smoke.rs b/crates/acp/tests/smoke.rs index 750bf0b3..5596412c 100644 --- a/crates/acp/tests/smoke.rs +++ b/crates/acp/tests/smoke.rs @@ -20,7 +20,7 @@ impl EventSink for PrintSink { async fn gemini_prompt_roundtrip() { let state = AcpState::new(Arc::new(PrintSink)); let cwd = std::env::temp_dir().display().to_string(); - let started = state.start("gemini", &cwd, None).await.expect("start"); + let started = state.start("gemini", &cwd, None, None).await.expect("start"); println!("initialize: {}", started.initialize); assert!( started.session_id.is_some(), @@ -66,7 +66,7 @@ async fn gemini_prompt_roundtrip() { async fn grok_reports_session_config() { let state = AcpState::new(Arc::new(PrintSink)); let cwd = std::env::temp_dir().display().to_string(); - let started = state.start("grok", &cwd, None).await.expect("start"); + let started = state.start("grok", &cwd, None, None).await.expect("start"); let session = started.session.expect("session/new result"); println!("models: {}", session.get("models").unwrap_or(&Value::Null)); println!("modes: {}", session.get("modes").unwrap_or(&Value::Null)); diff --git a/crates/db/src/acp_sessions.rs b/crates/db/src/acp_sessions.rs index b7b18a8e..eecfb881 100644 --- a/crates/db/src/acp_sessions.rs +++ b/crates/db/src/acp_sessions.rs @@ -12,6 +12,9 @@ pub struct AcpSessionRecord { pub agent_id: String, pub agent_title: Option, pub cwd: String, + /// The bot this conversation belongs to, when it was opened from the Bot + /// tab. `None` for the ordinary per-project ACP sessions. + pub bot_id: Option, /// First user message of the session, used as the list label. pub title: Option, pub created_at: String, @@ -27,18 +30,20 @@ pub fn upsert_session( agent_id: &str, agent_title: Option<&str>, cwd: &str, + bot_id: Option<&str>, ) -> Result<(), String> { let conn = get_connection()?; let now = Utc::now().to_rfc3339(); conn.execute( "INSERT INTO acp_sessions ( - session_id, agent_id, agent_title, cwd, title, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?5) + session_id, agent_id, agent_title, cwd, bot_id, title, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?6) ON CONFLICT(session_id) DO UPDATE SET agent_id = excluded.agent_id, agent_title = excluded.agent_title, - cwd = excluded.cwd", - params![session_id, agent_id, agent_title, cwd, now], + cwd = excluded.cwd, + bot_id = excluded.bot_id", + params![session_id, agent_id, agent_title, cwd, bot_id, now], ) .map_err(|e| format!("Failed to upsert ACP session: {}", e))?; Ok(()) @@ -75,15 +80,17 @@ pub fn set_title_if_empty(session_id: &str, title: &str) -> Result<(), String> { } /// Sessions for `cwd`, or all of them when `cwd` is `None`, newest first. +/// Bot conversations are left out: they belong to a bot, not to a project, and +/// listing them beside the project's own sessions would show each twice. pub fn list_sessions(cwd: Option<&str>, limit: usize) -> Result, String> { let conn = get_connection()?; let limit = if limit == 0 { 100 } else { limit.min(500) } as i64; let mut stmt = conn .prepare( - "SELECT session_id, agent_id, agent_title, cwd, title, created_at, updated_at + "SELECT session_id, agent_id, agent_title, cwd, bot_id, title, created_at, updated_at FROM acp_sessions - WHERE (?1 IS NULL OR cwd = ?1) + WHERE (?1 IS NULL OR cwd = ?1) AND bot_id IS NULL ORDER BY updated_at DESC LIMIT ?2", ) @@ -96,9 +103,10 @@ pub fn list_sessions(cwd: Option<&str>, limit: usize) -> Result, limit: usize) -> Result Result, String> { + let conn = get_connection()?; + let limit = if limit == 0 { 100 } else { limit.min(500) } as i64; + + let mut stmt = conn + .prepare( + "SELECT session_id, agent_id, agent_title, cwd, bot_id, title, created_at, updated_at + FROM acp_sessions + WHERE bot_id = ?1 + ORDER BY updated_at DESC + LIMIT ?2", + ) + .map_err(|e| format!("Failed to prepare bot session list query: {}", e))?; + + let rows = stmt + .query_map(params![bot_id, limit], |row| { + Ok(AcpSessionRecord { + session_id: row.get(0)?, + agent_id: row.get(1)?, + agent_title: row.get(2)?, + cwd: row.get(3)?, + bot_id: row.get(4)?, + title: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + }) + }) + .map_err(|e| format!("Failed to query bot sessions: {}", e))?; + + rows.collect::, _>>() + .map_err(|e| format!("Failed to read bot sessions: {}", e)) +} + /// The stored transcript, in arrival order. Each item is a `session/update` /// payload the frontend replays through its normal update handler. pub fn get_updates(session_id: &str) -> Result, String> { diff --git a/crates/db/src/bots.rs b/crates/db/src/bots.rs new file mode 100644 index 00000000..060aca46 --- /dev/null +++ b/crates/db/src/bots.rs @@ -0,0 +1,257 @@ +use chrono::Utc; +use rusqlite::params; +use serde::{Deserialize, Serialize}; + +use super::get_connection; + +/// A bot: a named, long-lived agent you message like a colleague. +/// +/// Everything here is Codexia's own. The runtime it drives (`keke agent stdio`) +/// has no concept of a named agent, so identity, persona and tool selection are +/// stored on this side and translated into spawn arguments and ACP config +/// options when a conversation starts. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BotRecord { + pub id: String, + pub name: String, + /// One-line role, shown under the name. + pub title: Option, + /// Emoji drawn on the avatar circle. + pub avatar: String, + /// Hex background of the avatar circle. + pub color: String, + /// Which ACP agent backs the bot. Always `keke` today; stored so another + /// preset can be offered without a migration. + pub agent_id: String, + pub provider: Option, + pub model: Option, + pub reasoning_effort: Option, + pub cwd: String, + /// The persona, handed to keke as `KEKE_INSTRUCTIONS`. + pub system_prompt: Option, + /// `read_only` | `ask` | `autonomous`. + pub trust_level: String, + /// Tool names this bot's owner has already approved for good, as a JSON + /// array. ACP's own `allow-always` only lasts a session, so the standing + /// answer is kept here instead. + pub approved_tools: String, + /// Servers to hand this bot at `session/new`, as a JSON array of names. + /// Per-conversation rather than per-installation, which is the only place + /// ACP lets a client name them. + pub mcp_servers: String, + pub pinned: bool, + pub archived: bool, + pub notifications_enabled: bool, + pub unread_count: i64, + pub last_viewed_at: Option, + pub created_at: String, + pub updated_at: String, +} + +/// The fields a caller may change. Anything left `None` keeps its stored value, +/// so a dialog that edits one field does not have to send the whole record back. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BotPatch { + pub name: Option, + pub title: Option, + pub avatar: Option, + pub color: Option, + pub provider: Option, + pub model: Option, + pub reasoning_effort: Option, + pub cwd: Option, + pub system_prompt: Option, + pub trust_level: Option, + pub approved_tools: Option>, + pub mcp_servers: Option>, + pub pinned: Option, + pub archived: Option, + pub notifications_enabled: Option, + pub unread_count: Option, + pub last_viewed_at: Option, +} + +const COLUMNS: &str = "id, name, title, avatar, color, agent_id, provider, model, \ + reasoning_effort, cwd, system_prompt, trust_level, approved_tools, mcp_servers, \ + pinned, archived, notifications_enabled, unread_count, \ + last_viewed_at, created_at, updated_at"; + +fn row_to_bot(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(BotRecord { + id: row.get(0)?, + name: row.get(1)?, + title: row.get(2)?, + avatar: row.get(3)?, + color: row.get(4)?, + agent_id: row.get(5)?, + provider: row.get(6)?, + model: row.get(7)?, + reasoning_effort: row.get(8)?, + cwd: row.get(9)?, + system_prompt: row.get(10)?, + trust_level: row.get(11)?, + approved_tools: row.get(12)?, + mcp_servers: row.get(13)?, + pinned: row.get(14)?, + archived: row.get(15)?, + notifications_enabled: row.get(16)?, + unread_count: row.get(17)?, + last_viewed_at: row.get(18)?, + created_at: row.get(19)?, + updated_at: row.get(20)?, + }) +} + +/// A JSON array of strings, as the list columns are stored. An unreadable +/// column reads as empty rather than failing the whole record: a bot with a +/// corrupt tool list is still a bot you can open and fix. +pub fn parse_list(raw: &str) -> Vec { + serde_json::from_str(raw).unwrap_or_default() +} + +fn encode_list(list: &[String]) -> String { + serde_json::to_string(list).unwrap_or_else(|_| "[]".to_string()) +} + +#[allow(clippy::too_many_arguments)] +pub fn create_bot( + id: &str, + name: &str, + avatar: &str, + color: &str, + agent_id: &str, + cwd: &str, + trust_level: &str, +) -> Result { + let conn = get_connection()?; + let now = Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO bots ( + id, name, title, avatar, color, agent_id, provider, model, reasoning_effort, + cwd, system_prompt, trust_level, approved_tools, mcp_servers, + pinned, archived, notifications_enabled, unread_count, last_viewed_at, + created_at, updated_at + ) VALUES ( + ?1, ?2, NULL, ?3, ?4, ?5, NULL, NULL, NULL, + ?6, NULL, ?7, '[]', '[]', + 0, 0, 1, 0, NULL, + ?8, ?8 + )", + params![id, name, avatar, color, agent_id, cwd, trust_level, now], + ) + .map_err(|e| format!("Failed to create bot: {}", e))?; + + get_bot(id)?.ok_or_else(|| "Failed to read back the created bot".to_string()) +} + +pub fn get_bot(id: &str) -> Result, String> { + let conn = get_connection()?; + let mut stmt = conn + .prepare(&format!("SELECT {COLUMNS} FROM bots WHERE id = ?1")) + .map_err(|e| format!("Failed to prepare bot query: {}", e))?; + let mut rows = stmt + .query_map(params![id], row_to_bot) + .map_err(|e| format!("Failed to query bot: {}", e))?; + match rows.next() { + Some(row) => Ok(Some( + row.map_err(|e| format!("Failed to read bot: {}", e))?, + )), + None => Ok(None), + } +} + +/// Every bot, pinned first and then by most recent activity — the order the +/// sidebar shows them in. Reading this must never start an agent process, so +/// it answers from the table alone. +pub fn list_bots(include_archived: bool) -> Result, String> { + let conn = get_connection()?; + let mut stmt = conn + .prepare(&format!( + "SELECT {COLUMNS} FROM bots + WHERE (?1 = 1 OR archived = 0) + ORDER BY pinned DESC, updated_at DESC" + )) + .map_err(|e| format!("Failed to prepare bot list query: {}", e))?; + + let rows = stmt + .query_map(params![include_archived as i64], row_to_bot) + .map_err(|e| format!("Failed to query bots: {}", e))?; + + rows.collect::, _>>() + .map_err(|e| format!("Failed to read bots: {}", e)) +} + +/// Apply the fields the caller set. `updated_at` moves only when something +/// actually changed, so opening a bot does not reorder the sidebar. +pub fn update_bot(id: &str, patch: &BotPatch) -> Result { + let conn = get_connection()?; + + let mut sets: Vec<&str> = Vec::new(); + let mut values: Vec> = Vec::new(); + + macro_rules! set { + ($field:ident, $column:literal) => { + if let Some(value) = patch.$field.clone() { + sets.push(concat!($column, " = ?")); + values.push(Box::new(value)); + } + }; + } + macro_rules! set_list { + ($field:ident, $column:literal) => { + if let Some(list) = &patch.$field { + sets.push(concat!($column, " = ?")); + values.push(Box::new(encode_list(list))); + } + }; + } + + set!(name, "name"); + set!(title, "title"); + set!(avatar, "avatar"); + set!(color, "color"); + set!(provider, "provider"); + set!(model, "model"); + set!(reasoning_effort, "reasoning_effort"); + set!(cwd, "cwd"); + set!(system_prompt, "system_prompt"); + set!(trust_level, "trust_level"); + set_list!(approved_tools, "approved_tools"); + set_list!(mcp_servers, "mcp_servers"); + set!(pinned, "pinned"); + set!(archived, "archived"); + set!(notifications_enabled, "notifications_enabled"); + set!(unread_count, "unread_count"); + set!(last_viewed_at, "last_viewed_at"); + + if !sets.is_empty() { + sets.push("updated_at = ?"); + values.push(Box::new(Utc::now().to_rfc3339())); + values.push(Box::new(id.to_string())); + + let sql = format!("UPDATE bots SET {} WHERE id = ?", sets.join(", ")); + let refs: Vec<&dyn rusqlite::ToSql> = values.iter().map(|v| v.as_ref()).collect(); + conn.execute(&sql, refs.as_slice()) + .map_err(|e| format!("Failed to update bot: {}", e))?; + } + + get_bot(id)?.ok_or_else(|| format!("No bot with id `{id}`")) +} + +/// Remove the bot and every conversation belonging to it, transcripts included. +pub fn delete_bot(id: &str) -> Result<(), String> { + let conn = get_connection()?; + conn.execute( + "DELETE FROM acp_session_updates WHERE session_id IN + (SELECT session_id FROM acp_sessions WHERE bot_id = ?1)", + params![id], + ) + .map_err(|e| format!("Failed to delete bot transcripts: {}", e))?; + conn.execute("DELETE FROM acp_sessions WHERE bot_id = ?1", params![id]) + .map_err(|e| format!("Failed to delete bot sessions: {}", e))?; + conn.execute("DELETE FROM bots WHERE id = ?1", params![id]) + .map_err(|e| format!("Failed to delete bot: {}", e))?; + Ok(()) +} diff --git a/crates/db/src/conn.rs b/crates/db/src/conn.rs index f90cae6c..c549a475 100644 --- a/crates/db/src/conn.rs +++ b/crates/db/src/conn.rs @@ -24,6 +24,48 @@ fn init_tables(conn: &Connection) -> Result<(), String> { init_notes_table(conn)?; init_automation_runs_tables(conn)?; init_acp_sessions_tables(conn)?; + init_bots_table(conn)?; + Ok(()) +} + +/// Create the bot registry. Conversations are not stored here: a bot's history +/// is its ACP sessions, tagged with `acp_sessions.bot_id`. +fn init_bots_table(conn: &Connection) -> Result<(), String> { + conn.execute( + "CREATE TABLE IF NOT EXISTS bots ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + title TEXT, + avatar TEXT NOT NULL, + color TEXT NOT NULL, + agent_id TEXT NOT NULL, + provider TEXT, + model TEXT, + reasoning_effort TEXT, + cwd TEXT NOT NULL, + system_prompt TEXT, + trust_level TEXT NOT NULL, + approved_tools TEXT NOT NULL DEFAULT '[]', + mcp_servers TEXT NOT NULL DEFAULT '[]', + pinned BOOLEAN NOT NULL DEFAULT 0, + archived BOOLEAN NOT NULL DEFAULT 0, + notifications_enabled BOOLEAN NOT NULL DEFAULT 1, + unread_count INTEGER NOT NULL DEFAULT 0, + last_viewed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + [], + ) + .map_err(|e| format!("Failed to create bots table: {}", e))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_bots_pinned_updated + ON bots(pinned DESC, updated_at DESC)", + [], + ) + .map_err(|e| format!("Failed to create bots index: {}", e))?; + Ok(()) } @@ -54,6 +96,21 @@ fn init_acp_sessions_tables(conn: &Connection) -> Result<(), String> { ) .map_err(|e| format!("Failed to create acp_session_updates table: {}", e))?; + // Added after the table shipped, so an existing database gets it here. + if let Err(err) = conn.execute("ALTER TABLE acp_sessions ADD COLUMN bot_id TEXT", []) { + let message = err.to_string(); + if !message.contains("duplicate column name") { + return Err(format!("Failed to add acp_sessions.bot_id column: {message}")); + } + } + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_acp_sessions_bot_updated + ON acp_sessions(bot_id, updated_at DESC)", + [], + ) + .map_err(|e| format!("Failed to create acp_sessions bot index: {}", e))?; + conn.execute( "CREATE INDEX IF NOT EXISTS idx_acp_sessions_cwd_updated ON acp_sessions(cwd, updated_at DESC)", diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index a2ab984b..bd0bb6c6 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -1,6 +1,7 @@ mod conn; pub mod acp_sessions; pub mod automation_runs; +pub mod bots; pub mod notes; pub(crate) use conn::get_connection; diff --git a/src/components/acp/AcpChoiceMenu.tsx b/src/components/acp/AcpChoiceMenu.tsx new file mode 100644 index 00000000..266271c6 --- /dev/null +++ b/src/components/acp/AcpChoiceMenu.tsx @@ -0,0 +1,230 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Switch } from '@/components/ui/switch'; +import { useExternalUrl } from '@/features/plugins/hooks/useExternalUrl'; +import type { AcpAuthMethod, AcpConfigOption, AcpModelState } from '@/services/apiAdapt/acp'; +import { EFFORT_SEPARATOR, effortKey, modelChoicesFor } from './modelChoices'; + +function findUrl(text: string): string | null { + return text.match(/https?:\/\/\S+/)?.[0] ?? null; +} + +interface AcpChoiceMenuProps { + /** Accounts the agent advertises. Empty hides the Account submenu. */ + authMethods: AcpAuthMethod[]; + selectedAuthMethod: string | null; + onSelectAuthMethod: (methodId: string) => void; + /** Id of the account currently signing in, for the live composer only. */ + authenticating?: string | null; + authNotice?: string | null; + configOptions: AcpConfigOption[]; + onConfigOptionChange: (option: AcpConfigOption, value: string | boolean) => void; + models: AcpModelState | null; + reasoningEffort: string | null; + onModelChange: (modelId: string, effort: string | null) => void; + /** Name of the account row. Bots pick a provider rather than sign in. */ + accountLabel?: string; + /** What the account row reads when nothing is picked. */ + noAccountLabel?: string; + /** Shown on the trigger when nothing is picked yet. */ + placeholder?: string; + triggerClassName?: string; +} + +/** + * Account, model and reasoning effort in one dropdown — one trigger rather + * than a row of separate pickers. + * + * Presentation only: it renders whatever choices it is handed and reports + * picks back. `AcpModelMenu` wires it to a live connection (each pick is an + * immediate RPC); the bot settings dialog wires it to a stored draft for an + * agent that isn't running. + */ +export function AcpChoiceMenu({ + authMethods, + selectedAuthMethod, + onSelectAuthMethod, + authenticating = null, + authNotice = null, + configOptions, + onConfigOptionChange, + models, + reasoningEffort, + onModelChange, + accountLabel: accountLabelText = 'Account', + noAccountLabel = 'Sign in', + placeholder = 'Model', + triggerClassName = 'flex max-w-40 items-center gap-1 truncate rounded-sm px-2 py-1 text-xs transition-colors hover:bg-accent', +}: AcpChoiceMenuProps) { + const { openExternalUrl } = useExternalUrl(); + + const nonModeOptions = configOptions.filter((o) => o.category !== 'mode'); + const modelChoices = modelChoicesFor(models); + const currentModelChoice = models + ? (modelChoices.find((c) => c.value === effortKey(models.currentModelId, reasoningEffort)) ?? + modelChoices.find((c) => c.value.startsWith(models.currentModelId))) + : undefined; + + if (nonModeOptions.length === 0 && modelChoices.length === 0 && authMethods.length === 0) + return null; + + const modelCategoryOption = nonModeOptions.find( + (o) => o.category === 'model' || o.category === 'model_config' + ); + const modelCategoryLabel = modelCategoryOption?.currentValue + ? (modelCategoryOption.options?.find((o) => o.value === modelCategoryOption.currentValue) + ?.name ?? String(modelCategoryOption.currentValue)) + : null; + const effortCategoryOption = nonModeOptions.find((o) => o.category === 'thought_level'); + const effortCategoryLabel = effortCategoryOption?.currentValue + ? (effortCategoryOption.options?.find((o) => o.value === effortCategoryOption.currentValue) + ?.name ?? String(effortCategoryOption.currentValue)) + : null; + // `currentModelChoice.name` already folds the effort in for the legacy + // `models` mechanism (e.g. "Grok 4 · High"); the `configOptions` one keeps + // model and effort as separate options, so pair them here instead. + const modelLabel = modelCategoryLabel + ? effortCategoryLabel + ? `${modelCategoryLabel} · ${effortCategoryLabel}` + : modelCategoryLabel + : (currentModelChoice?.name ?? models?.currentModelId ?? null); + const accountLabel = authenticating + ? 'Signing in…' + : (authMethods.find((m) => m.id === selectedAuthMethod)?.name ?? + (authMethods.length ? noAccountLabel : null)); + const triggerLabel = modelLabel ?? accountLabel; + + const noticeUrl = authNotice ? findUrl(authNotice) : null; + + return ( + + + {triggerLabel ?? placeholder} + + + + {authMethods.length > 0 && ( + + +
+ {accountLabelText} + + {accountLabel ?? noAccountLabel} + +
+
+ + {accountLabelText} + {authMethods.map((m) => ( + { + e.preventDefault(); + onSelectAuthMethod(m.id); + }} + > + {m.name} + {authenticating === m.id && ( + … + )} + + ))} + {authenticating && authNotice && ( +
+ {noticeUrl ? ( + + ) : ( + authNotice + )} +
+ )} +
+
+ )} + + {nonModeOptions.map((option) => + option.type === 'boolean' ? ( + + ) : ( + + +
+ {option.name} + + {String(option.currentValue ?? '')} + +
+
+ + {(option.options ?? []).map((o) => ( + onConfigOptionChange(option, o.value)} + > + {o.name} + + ))} + +
+ ) + )} + + {configOptions.length === 0 && modelChoices.length > 0 && ( + + +
+ Model + + {currentModelChoice?.name ?? models?.currentModelId} + +
+
+ + {modelChoices.map((c) => ( + onModelChange(c.value.split(EFFORT_SEPARATOR)[0], c.effort)} + > + {c.name} + + ))} + +
+ )} +
+
+
+ ); +} diff --git a/src/components/acp/AcpComposer.tsx b/src/components/acp/AcpComposer.tsx index d1ce81d7..ade386e7 100644 --- a/src/components/acp/AcpComposer.tsx +++ b/src/components/acp/AcpComposer.tsx @@ -5,6 +5,7 @@ import { toast } from '@/components/ui/use-toast'; import { acpCancel, acpPrompt, acpStart } from '@/services/apiAdapt/acp'; import { useWorkspaceStore } from '@/stores'; import { useAcpStore } from '@/stores/useAcpStore'; +import { captureBotOptions } from '@/stores/useBotOptionsStore'; import { AcpModelMenu } from './AcpModelMenu'; import { AcpSessionControls } from './AcpSessionControls'; import { useAcpAgents } from './useAcpAgents'; @@ -24,7 +25,7 @@ export function AcpComposer() { restartNonce, } = useAcpStore(); const cwd = useWorkspaceStore((s) => s.cwd); - const agents = useAcpAgents(); + const agents = useAcpAgents() ?? []; const [text, setText] = useState(''); // Agent we already tried to auto-connect, so a failed start does not spin in // a retry loop. Cleared on an explicit restart. @@ -52,6 +53,9 @@ export function AcpComposer() { canLoadSession: res.initialize.agentCapabilities?.loadSession === true, }); applySession(res.session); + // keke's own catalogue also configures bots, which are keke processes — + // so a bot can be set up from this session without opening its chat. + if (agentId === 'keke') captureBotOptions(res.initialize, res.session); if (res.sessionError) { addEntry({ id: `start-${Date.now()}`, role: 'error', text: res.sessionError }); return null; diff --git a/src/components/acp/AcpModelMenu.tsx b/src/components/acp/AcpModelMenu.tsx index ec31b335..77893d2f 100644 --- a/src/components/acp/AcpModelMenu.tsx +++ b/src/components/acp/AcpModelMenu.tsx @@ -1,17 +1,4 @@ -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { Switch } from '@/components/ui/switch'; import { toast } from '@/components/ui/use-toast'; -import { useExternalUrl } from '@/features/plugins/hooks/useExternalUrl'; import { type AcpConfigOption, acpAuthenticate, @@ -20,30 +7,18 @@ import { } from '@/services/apiAdapt/acp'; import { useWorkspaceStore } from '@/stores'; import { useAcpStore } from '@/stores/useAcpStore'; +import { AcpChoiceMenu } from './AcpChoiceMenu'; import { acpFreshSession } from './newSession'; -type ChoiceOption = { value: string; name: string; description?: string; effort: string | null }; - -/** Model id and reasoning effort share one select value. */ -const EFFORT_SEPARATOR = '::'; -const key = (modelId: string, effort: string | null) => - effort ? `${modelId}${EFFORT_SEPARATOR}${effort}` : modelId; - -function findUrl(text: string): string | null { - return text.match(/https?:\/\/\S+/)?.[0] ?? null; -} - /** Loose match between an auth method id (e.g. "grok") and a model id/name it likely owns. */ function ownedByMethod(text: string, methodId: string): boolean { return text.toLowerCase().includes(methodId.toLowerCase()); } /** - * Account, model and reasoning-effort in one dropdown — one trigger rather - * than a row of separate pickers, mirroring grok-web-ui's `ModelSelector` / - * `AccountMenu` split. Only `keke`'s `configOptions` path has been exercised - * against a real agent; the legacy `models` flattening (Grok/Gemini) is - * carried over unchanged from the previous per-slot control. + * The composer's account/model/effort menu, wired to the live connection: + * every pick is applied to the running agent straight away, and reverted if + * the agent rejects it. `AcpChoiceMenu` does the rendering. * * There is no `signedIn` status from the backend — `authenticate` is * fire-and-forget — so "Account" only reflects the last method we asked for @@ -66,12 +41,10 @@ export function AcpModelMenu() { setAuthenticating, setSelectedAuthMethod, } = useAcpStore(); - const { openExternalUrl } = useExternalUrl(); const cwd = useWorkspaceStore((s) => s.cwd); if (!connectionId || !sessionId) return null; - // Update optimistically, then roll back if the agent rejects the change. const apply = async (revert: () => void, request: () => Promise) => { try { await request(); @@ -81,9 +54,6 @@ export function AcpModelMenu() { } }; - // Read the session id fresh rather than closing over the render-time one — - // `selectModelForMethod` calls these right after `acpFreshSession` swaps in - // a new session id, and targeting the stale one would fail silently. const changeConfigOption = (option: AcpConfigOption, value: string | boolean) => { const previous = option.currentValue; setConfigOptionValue(option.id, value); @@ -107,10 +77,6 @@ export function AcpModelMenu() { ); }; - // After switching account, pick a model that actually belongs to it — a - // fresh session refreshes the *list*, but the agent's own "current" model - // may still be the old provider's, and the list can mix every provider's - // models together rather than filtering to the one just signed in to. const selectModelForMethod = async (methodId: string) => { const s = useAcpStore.getState(); if (!s.connectionId || !s.sessionId) return; @@ -124,10 +90,6 @@ export function AcpModelMenu() { (o) => ownedByMethod(o.value, methodId) || ownedByMethod(o.name, methodId) ) ?? modelOption.options[0]; if (match.value !== modelOption.currentValue) { - // Effort choices can depend on the model (thought_level is often - // model-specific), so wait for the agent's response — and re-read the - // store rather than the `s` snapshot from before the change — instead - // of reading a list that was still the previous model's. await changeConfigOption(modelOption, match.value); } const effortOption = useAcpStore @@ -171,180 +133,18 @@ export function AcpModelMenu() { } }; - const nonModeOptions = configOptions.filter((o) => o.category !== 'mode'); - - // Effort belongs to a model, so expand each model that offers levels into - // one entry per level rather than pairing the picker with a second menu. - const modelChoices: ChoiceOption[] = (models?.availableModels ?? []).flatMap( - (m) => { - const efforts = m._meta?.reasoningEfforts ?? []; - if (!efforts.length) { - return [{ value: m.modelId, name: m.name, description: m.description, effort: null }]; - } - return efforts.map((e) => ({ - value: key(m.modelId, e.id), - name: `${m.name} · ${(e.label ?? e.id).replace(/\s*effort$/i, '')}`, - description: e.description ?? m.description, - effort: e.id, - })); - } - ); - const currentModelChoice = models - ? (modelChoices.find((c) => c.value === key(models.currentModelId, reasoningEffort)) ?? - modelChoices.find((c) => c.value.startsWith(models.currentModelId))) - : undefined; - - if (nonModeOptions.length === 0 && modelChoices.length === 0 && authMethods.length === 0) - return null; - - const modelCategoryOption = nonModeOptions.find( - (o) => o.category === 'model' || o.category === 'model_config' - ); - const modelCategoryLabel = modelCategoryOption - ? (modelCategoryOption.options?.find((o) => o.value === modelCategoryOption.currentValue) - ?.name ?? String(modelCategoryOption.currentValue)) - : null; - const effortCategoryOption = nonModeOptions.find((o) => o.category === 'thought_level'); - const effortCategoryLabel = effortCategoryOption - ? (effortCategoryOption.options?.find((o) => o.value === effortCategoryOption.currentValue) - ?.name ?? String(effortCategoryOption.currentValue)) - : null; - // `currentModelChoice.name` already folds the effort in for the legacy - // `models` mechanism (e.g. "Grok 4 · High"); the `configOptions` one keeps - // model and effort as separate options, so pair them here instead. - const modelLabel = modelCategoryLabel - ? effortCategoryLabel - ? `${modelCategoryLabel} · ${effortCategoryLabel}` - : modelCategoryLabel - : (currentModelChoice?.name ?? models?.currentModelId ?? null); - const accountLabel = authenticating - ? 'Signing in…' - : (authMethods.find((m) => m.id === selectedAuthMethod)?.name ?? - (authMethods.length ? 'Sign in' : null)); - const triggerLabel = modelLabel ?? accountLabel; - - const noticeUrl = authNotice ? findUrl(authNotice) : null; - return ( - - - {triggerLabel ?? 'Model'} - - - - {authMethods.length > 0 && ( - - -
- Account - - {accountLabel ?? 'Not signed in'} - -
-
- - Account - {authMethods.map((m) => ( - { - e.preventDefault(); - void signIn(m.id); - }} - > - {m.name} - {authenticating === m.id && ( - … - )} - - ))} - {authenticating && authNotice && ( -
- {noticeUrl ? ( - - ) : ( - authNotice - )} -
- )} -
-
- )} - - {nonModeOptions.map((option) => - option.type === 'boolean' ? ( - - ) : ( - - -
- {option.name} - - {String(option.currentValue)} - -
-
- - {(option.options ?? []).map((o) => ( - changeConfigOption(option, o.value)} - > - {o.name} - - ))} - -
- ) - )} - - {configOptions.length === 0 && modelChoices.length > 0 && ( - - -
- Model - - {currentModelChoice?.name ?? models?.currentModelId} - -
-
- - {modelChoices.map((c) => ( - changeModel(c.value.split(EFFORT_SEPARATOR)[0], c.effort)} - > - {c.name} - - ))} - -
- )} -
-
-
+ void signIn(methodId)} + authenticating={authenticating} + authNotice={authNotice} + configOptions={configOptions} + onConfigOptionChange={changeConfigOption} + models={models} + reasoningEffort={reasoningEffort} + onModelChange={changeModel} + /> ); } diff --git a/src/components/acp/AcpToolCall.test.tsx b/src/components/acp/AcpToolCall.test.tsx new file mode 100644 index 00000000..fad0517a --- /dev/null +++ b/src/components/acp/AcpToolCall.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { AcpEntry } from '@/stores/useAcpStore'; +import { AcpToolCall } from './AcpToolCall'; + +type ToolEntry = Extract; + +function toolEntry(overrides: Partial): ToolEntry { + return { + id: 't1', + role: 'tool', + toolCallId: 'call-1', + title: 'Run command', + status: 'completed', + ...overrides, + } as ToolEntry; +} + +describe('AcpToolCall', () => { + it('shows the command of a shell call that already has output', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button')); + expect(screen.getByText(/ls -la/)).toBeTruthy(); + expect(screen.getByText(/total 0/)).toBeTruthy(); + }); + + it('joins an argv-style command', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button')); + expect(screen.getByText(/bash -lc echo hi/)).toBeTruthy(); + }); + + it('falls back to the raw input when the call is not a command', () => { + render(); + + fireEvent.click(screen.getByRole('button')); + expect(screen.getByText(/\/tmp\/a.txt/)).toBeTruthy(); + }); +}); diff --git a/src/components/acp/AcpToolCall.tsx b/src/components/acp/AcpToolCall.tsx index 89987b82..5bf9a4d3 100644 --- a/src/components/acp/AcpToolCall.tsx +++ b/src/components/acp/AcpToolCall.tsx @@ -46,6 +46,20 @@ function ToolContent({ item }: { item: AcpToolContent }) { ); } +/** + * The shell command a tool call runs, when it has one. Agents name the field + * differently (`command`, `cmd`) and some pass argv rather than a string. + */ +function commandOf(rawInput: unknown): string | null { + if (!rawInput || typeof rawInput !== 'object') return null; + const raw = rawInput as Record; + const value = raw.command ?? raw.cmd; + if (typeof value === 'string') return value.trim() || null; + if (Array.isArray(value)) + return value.filter((part) => typeof part === 'string').join(' ') || null; + return null; +} + /** * One ACP tool call: a title + status row that expands into whatever the agent * attached — diffs, command output, or the raw input when there is no content. @@ -53,9 +67,14 @@ function ToolContent({ item }: { item: AcpToolContent }) { export function AcpToolCall({ entry }: { entry: ToolEntry }) { const [open, setOpen] = useState(false); const content = entry.content ?? []; + const command = commandOf(entry.rawInput); + // The command is what a shell call is about, so it stays visible even once + // output arrives; the raw JSON is only the fallback for calls with neither. const rawInput = - content.length === 0 && entry.rawInput ? JSON.stringify(entry.rawInput, null, 2) : null; - const expandable = content.length > 0 || rawInput !== null; + content.length === 0 && !command && entry.rawInput + ? JSON.stringify(entry.rawInput, null, 2) + : null; + const expandable = content.length > 0 || rawInput !== null || command !== null; return ( @@ -82,6 +101,11 @@ export function AcpToolCall({ entry }: { entry: ToolEntry }) { + {command && ( +
+            $ {command}
+          
+ )} {content.map((item, i) => ( + effort ? `${modelId}${EFFORT_SEPARATOR}${effort}` : modelId; + +/** + * Effort belongs to a model, so expand each model that offers levels into one + * entry per level rather than pairing the picker with a second menu. Only the + * legacy `models` mechanism (Grok/Gemini) needs this — `configOptions` keeps + * model and effort as separate options already. + */ +export function modelChoicesFor(models: AcpModelState | null): ChoiceOption[] { + return (models?.availableModels ?? []).flatMap((m) => { + const efforts = m._meta?.reasoningEfforts ?? []; + if (!efforts.length) { + return [{ value: m.modelId, name: m.name, description: m.description, effort: null }]; + } + return efforts.map((e) => ({ + value: effortKey(m.modelId, e.id), + name: `${m.name} · ${(e.label ?? e.id).replace(/\s*effort$/i, '')}`, + description: e.description ?? m.description, + effort: e.id, + })); + }); +} diff --git a/src/components/acp/useAcpAgents.ts b/src/components/acp/useAcpAgents.ts index b05f52b1..549590c6 100644 --- a/src/components/acp/useAcpAgents.ts +++ b/src/components/acp/useAcpAgents.ts @@ -1,22 +1,54 @@ import { useEffect, useState } from 'react'; import { type AcpAgentDef, acpListAgents } from '@/services/apiAdapt/acp'; -// The preset list is static for the lifetime of the app; fetch it once and -// share it between the agent picker and the composer. let cache: AcpAgentDef[] | null = null; let inflight: Promise | null = null; +const listeners = new Set<(agents: AcpAgentDef[]) => void>(); -export function useAcpAgents() { - const [agents, setAgents] = useState(cache ?? []); +/** + * The resolved agent presets, or `null` while the first resolve is still in + * flight. `null` is deliberately distinct from `[]`: a caller must be able to + * tell "not known yet" from "nothing is installed", otherwise it renders an + * install prompt for an agent that is in fact present. + */ +export function useAcpAgents(): AcpAgentDef[] | null { + const [agents, setAgents] = useState(cache); useEffect(() => { - if (cache) return; - inflight ??= acpListAgents().catch(() => []); - inflight.then((list) => { - cache = list; - setAgents(list); - }); + listeners.add(setAgents); + if (!cache) loadAcpAgents().then(setAgents); + return () => { + listeners.delete(setAgents); + }; }, []); return agents; } + +/** + * The resolved list, awaited rather than read from React state. A component + * event handler (a message send, say) can fire before the hook's own effect + * has resolved its first render — awaiting this instead of trusting the + * hook's return value avoids treating "not loaded yet" as "not installed". + */ +export function loadAcpAgents(): Promise { + if (cache) return Promise.resolve(cache); + inflight ??= acpListAgents().catch(() => []); + return inflight.then((list) => { + cache = list; + return list; + }); +} + +/** + * Resolve the presets again from scratch. Availability is a PATH lookup made + * once per app run, so installing an agent while the app is open would + * otherwise keep reading as missing until a restart. + */ +export async function refreshAcpAgents(): Promise { + cache = null; + inflight = null; + const list = await loadAcpAgents(); + for (const listener of listeners) listener(list); + return list; +} diff --git a/src/components/acp/useAcpEvents.ts b/src/components/acp/useAcpEvents.ts index f2c18239..9f756d09 100644 --- a/src/components/acp/useAcpEvents.ts +++ b/src/components/acp/useAcpEvents.ts @@ -59,6 +59,7 @@ export function useAcpEvents(connectionId: string | null) { store.setPermission({ requestId: payload.requestId!, title, + toolKind: payload.toolCall?.kind as string | undefined, options: payload.options ?? [], }); return; diff --git a/src/components/agent/AgentSelector.tsx b/src/components/agent/AgentSelector.tsx index 4185b1ac..eb4e5d4c 100644 --- a/src/components/agent/AgentSelector.tsx +++ b/src/components/agent/AgentSelector.tsx @@ -31,7 +31,7 @@ export function AgentSelector() { const { selectedAgent, setSelectedAgent } = useAgentSettingsStore(); const { setActiveSidebarTab } = useLayoutStore(); const { active, agentId, connectionId, setActive, setAgentId, reset } = useAcpStore(); - const acpAgents = useAcpAgents(); + const acpAgents = useAcpAgents() ?? []; const [open, setOpen] = useState(false); const current = active diff --git a/src/components/bot/BotAvatar.tsx b/src/components/bot/BotAvatar.tsx new file mode 100644 index 00000000..3c8fd1b1 --- /dev/null +++ b/src/components/bot/BotAvatar.tsx @@ -0,0 +1,32 @@ +import type { Bot } from '@/services/apiAdapt/bots'; + +const SIZES = { + sm: 'h-7 w-7 text-sm', + md: 'h-9 w-9 text-base', + lg: 'h-11 w-11 text-xl', +} as const; + +interface BotAvatarProps { + bot: Pick; + size?: keyof typeof SIZES; + /** Draws the ring that marks a bot whose agent process is live. */ + running?: boolean; + className?: string; +} + +export function BotAvatar({ bot, size = 'md', running, className }: BotAvatarProps) { + return ( + + {bot.avatar} + + ); +} diff --git a/src/components/bot/BotChatView.tsx b/src/components/bot/BotChatView.tsx new file mode 100644 index 00000000..e45c2aef --- /dev/null +++ b/src/components/bot/BotChatView.tsx @@ -0,0 +1,83 @@ +import { Settings2 } from 'lucide-react'; +import { useState } from 'react'; +import { useAcpEvents } from '@/components/acp/useAcpEvents'; +import { Button } from '@/components/ui/button'; +import { SidebarTrigger, useSidebar } from '@/components/ui/sidebar'; +import { useTrafficLightConfig } from '@/hooks'; +import { useAcpStore } from '@/stores/useAcpStore'; +import { useBotUiStore } from '@/stores/useBotUiStore'; +import { BotAvatar } from './BotAvatar'; +import { BotComposer } from './BotComposer'; +import { BotMessageList } from './BotMessageList'; +import { BotPermissionGate } from './BotPermissionGate'; +import { BotSettingsDialog } from './BotSettingsDialog'; +import { TRUST_LEVELS } from './botAgentDef'; +import { useBotDragDrop } from './useBotDragDrop'; + +/** The full-screen conversation with one bot. */ +export default function BotChatView() { + const { bots, selectedBotId, connectionByBot } = useBotUiStore(); + const connectionId = useAcpStore((s) => s.connectionId); + const [settingsOpen, setSettingsOpen] = useState(false); + const { open: isSidebarOpen, openMobile, isMobile } = useSidebar(); + const showTrigger = isMobile ? !openMobile : !isSidebarOpen; + const { needsTrafficLightOffset } = useTrafficLightConfig(isSidebarOpen); + + useAcpEvents(connectionId); + + const bot = bots.find((b) => b.id === selectedBotId); + useBotDragDrop(bot); + + if (!bot) { + return ( +
+ Pick a bot, or make a new one. +
+ ); + } + + const trust = TRUST_LEVELS.find((level) => level.id === bot.trustLevel); + const running = Boolean(connectionByBot[bot.id]); + + return ( +
+
+
+ {showTrigger && } +
+ +
+
+ {bot.name} +
+
+ {[bot.title, bot.model, trust?.label].filter(Boolean).join(' · ')} +
+
+ +
+ + + + + + +
+ ); +} diff --git a/src/components/bot/BotComposer.tsx b/src/components/bot/BotComposer.tsx new file mode 100644 index 00000000..1470a10e --- /dev/null +++ b/src/components/bot/BotComposer.tsx @@ -0,0 +1,114 @@ +import { ArrowUp, Square } from 'lucide-react'; +import { useState } from 'react'; +import { useAcpAgents } from '@/components/acp/useAcpAgents'; +import { Button } from '@/components/ui/button'; +import { acpCancel, acpPrompt } from '@/services/apiAdapt/acp'; +import type { Bot } from '@/services/apiAdapt/bots'; +import { useAcpStore } from '@/stores/useAcpStore'; +import { useBotUiStore } from '@/stores/useBotUiStore'; +import { BotKekeInstall } from './BotKekeInstall'; +import { useBotSession } from './useBotSession'; + +export function BotComposer({ bot }: { bot: Bot }) { + const { connectionId, connecting, setRunning, addEntry } = useAcpStore(); + const connectionByBot = useBotUiStore((s) => s.connectionByBot); + const sessionByBot = useBotUiStore((s) => s.sessionByBot); + const kekeSpawnFailed = useBotUiStore((s) => s.kekeSpawnFailed); + const running = useBotUiStore((s) => Boolean(s.runningByBot[bot.id])); + const setBotRunning = useBotUiStore((s) => s.setBotRunning); + const { open } = useBotSession(); + const [text, setText] = useState(''); + + // Scoped to this bot: the ACP store can still hold another agent's live + // connection from the chat pane, which must not read as this bot's. + const botConnection = connectionByBot[bot.id]; + const botSession = sessionByBot[bot.id]; + const hasSession = Boolean(botConnection && botSession); + + // `null` while the agent list is still resolving — only an actually resolved + // list saying keke is missing should replace the composer. + // + // `local`, not `available`: with Node installed keke always "resolves", via + // the `npx -y @milisp/keke@latest` fallback, which re-downloads the package + // on every spawn and is why a bot could sit there doing nothing instead of + // offering to install. + const agents = useAcpAgents(); + const keke = agents?.find((a) => a.id === 'keke'); + const kekeMissing = !hasSession && ((keke !== undefined && !keke.local) || kekeSpawnFailed); + + if (kekeMissing) return ; + + const send = async () => { + const trimmed = text.trim(); + if (!trimmed || running || connecting) return; + + // Reuse this bot's own process when the store is already pointed at it; + // otherwise `open` restores or spawns it. + const live = + botConnection && botSession && connectionId === botConnection + ? { connectionId: botConnection, sessionId: botSession } + : await open(bot); + if (!live) return; + + setText(''); + addEntry({ id: `u-${Date.now()}`, role: 'user', text: trimmed }); + setBotRunning(bot.id, true); + setRunning(true); + try { + await acpPrompt(live.connectionId, live.sessionId, trimmed); + } catch (e) { + // Only the bot on screen owns the ACP store: a turn that ends while the + // user is reading another bot must not write into that bot's pane. + if (useBotUiStore.getState().selectedBotId === bot.id) { + addEntry({ id: `e-${Date.now()}`, role: 'error', text: String(e) }); + } + } finally { + setBotRunning(bot.id, false); + if (useBotUiStore.getState().selectedBotId === bot.id) setRunning(false); + } + }; + + const stop = async () => { + const connection = botConnection ?? connectionId; + if (!connection) return; + await acpCancel(connection, botSession ?? null).catch(() => {}); + setBotRunning(bot.id, false); + setRunning(false); + }; + + return ( +
+
+