From 9df8eba69659adf1f01f5d393f917d5e4cccb1f9 Mon Sep 17 00:00:00 2001 From: milisp Date: Mon, 7 Sep 2026 07:34:12 -0400 Subject: [PATCH 1/7] feat(bot): add a Bot tab with keke-backed chats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bot is a named, long-lived agent you keep messaging, listed the way a messages app lists conversations. Each bot owns its own keke process, its own workspace, persona, provider/model and trust level; the sidebar list is deliberately cold, so a process only starts when you open a bot, and stays up so coming back to one costs nothing. Settings read the live agent where they can — provider and model come from the running session's own lists once a bot has been opened, and are typed in by hand before that. keke's absence is handled where the user is looking: the composer is replaced by an inline prompt with an Install button that runs `npm install -g @milisp/keke` and re-resolves the presets, instead of a toast. That needs a def to distinguish "installed" from "runnable": every preset has an `npx -y @latest` fallback, so with Node on PATH every npm-published agent reports `available`. Defs now also carry `local` — the agent's own binary resolved — and the Bot tab keys off that, while the ACP chat pane keeps using `available`, where running through npx is a deliberate offer rather than an accident. Two smaller things the Bot tab needs to behave: - `useAcpAgents` returns null until the list has resolved, so a first render cannot mistake "still loading" for "nothing installed". - The composer reads liveness off this bot's own connection and session rather than the global ACP store, which can hold the chat pane's connection to a different agent — a stale one there neither hides the install prompt nor receives a bot's message. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PF1yKf8CW1SEePDThwVQrr --- crates/acp/src/agents.rs | 83 +++++ crates/acp/src/client.rs | 8 + crates/acp/src/lib.rs | 2 +- crates/acp/src/state.rs | 4 +- crates/acp/tests/smoke.rs | 4 +- crates/db/src/acp_sessions.rs | 61 +++- crates/db/src/bots.rs | 257 ++++++++++++++++ crates/db/src/conn.rs | 57 ++++ crates/db/src/lib.rs | 1 + src/components/acp/AcpComposer.tsx | 2 +- src/components/acp/useAcpAgents.ts | 52 +++- src/components/acp/useAcpEvents.ts | 1 + src/components/agent/AgentSelector.tsx | 2 +- src/components/bot/BotAvatar.tsx | 32 ++ src/components/bot/BotChatView.tsx | 62 ++++ src/components/bot/BotComposer.tsx | 104 +++++++ src/components/bot/BotKekeInstall.tsx | 56 ++++ src/components/bot/BotMessageList.tsx | 85 ++++++ src/components/bot/BotPermissionGate.tsx | 92 ++++++ src/components/bot/BotSettingsDialog.tsx | 338 +++++++++++++++++++++ src/components/bot/SideBarBotPane.tsx | 114 +++++++ src/components/bot/botAgentDef.ts | 58 ++++ src/components/bot/botDefaults.ts | 16 + src/components/bot/index.ts | 3 + src/components/bot/useBotSession.ts | 141 +++++++++ src/components/layout/AppLayout.tsx | 2 + src/components/layout/AppSidebar.tsx | 179 +++-------- src/components/layout/SideBarAgentPane.tsx | 163 ++++++++++ src/locales/en.ts | 3 + src/locales/fr.ts | 3 + src/locales/ja.ts | 3 + src/locales/zh.ts | 3 + src/services/apiAdapt/acp.ts | 44 ++- src/services/apiAdapt/bots.ts | 94 ++++++ src/services/apiAdapt/index.ts | 1 + src/stores/index.ts | 3 +- src/stores/useAcpStore.ts | 4 + src/stores/useBotUiStore.ts | 72 +++++ src/stores/useLayoutStore.ts | 19 +- web/src/handlers/acp.rs | 23 +- web/src/handlers/bots.rs | 102 +++++++ web/src/handlers/mod.rs | 2 + web/src/router.rs | 9 +- 43 files changed, 2183 insertions(+), 181 deletions(-) create mode 100644 crates/db/src/bots.rs create mode 100644 src/components/bot/BotAvatar.tsx create mode 100644 src/components/bot/BotChatView.tsx create mode 100644 src/components/bot/BotComposer.tsx create mode 100644 src/components/bot/BotKekeInstall.tsx create mode 100644 src/components/bot/BotMessageList.tsx create mode 100644 src/components/bot/BotPermissionGate.tsx create mode 100644 src/components/bot/BotSettingsDialog.tsx create mode 100644 src/components/bot/SideBarBotPane.tsx create mode 100644 src/components/bot/botAgentDef.ts create mode 100644 src/components/bot/botDefaults.ts create mode 100644 src/components/bot/index.ts create mode 100644 src/components/bot/useBotSession.ts create mode 100644 src/components/layout/SideBarAgentPane.tsx create mode 100644 src/services/apiAdapt/bots.ts create mode 100644 src/stores/useBotUiStore.ts create mode 100644 web/src/handlers/bots.rs 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/AcpComposer.tsx b/src/components/acp/AcpComposer.tsx index d1ce81d7..f3053c44 100644 --- a/src/components/acp/AcpComposer.tsx +++ b/src/components/acp/AcpComposer.tsx @@ -24,7 +24,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. 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..e094e8f4 --- /dev/null +++ b/src/components/bot/BotChatView.tsx @@ -0,0 +1,62 @@ +import { Settings2 } from 'lucide-react'; +import { useState } from 'react'; +import { useAcpEvents } from '@/components/acp/useAcpEvents'; +import { Button } from '@/components/ui/button'; +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'; + +/** 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); + + useAcpEvents(connectionId); + + const bot = bots.find((b) => b.id === selectedBotId); + 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 ( +
+
+ +
+
{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..80a90bdb --- /dev/null +++ b/src/components/bot/BotComposer.tsx @@ -0,0 +1,104 @@ +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, sessionId, connecting, running, setRunning, addEntry } = useAcpStore(); + const connectionByBot = useBotUiStore((s) => s.connectionByBot); + const sessionByBot = useBotUiStore((s) => s.sessionByBot); + const kekeSpawnFailed = useBotUiStore((s) => s.kekeSpawnFailed); + 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 }); + setRunning(true); + try { + await acpPrompt(live.connectionId, live.sessionId, trimmed); + } catch (e) { + addEntry({ id: `e-${Date.now()}`, role: 'error', text: String(e) }); + } finally { + setRunning(false); + } + }; + + const stop = async () => { + if (!connectionId) return; + await acpCancel(connectionId, sessionId).catch(() => {}); + setRunning(false); + }; + + return ( +
+
+