diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 99fe83a5c6f74c1989e2b5e2317d7c267d531eef..1a5764eca1b1861aa4c928aa5ede12e18c49e64b 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -31,6 +31,7 @@ use task::{Shell, ShellBuilder}; pub use terminal::*; use text::Bias; use ui::App; +use util::path_list::PathList; use util::{ResultExt, get_default_system_shell_preferring_bash, paths::PathStyle}; use uuid::Uuid; @@ -953,7 +954,7 @@ struct RunningTurn { pub struct AcpThread { session_id: acp::SessionId, - cwd: Option, + work_dirs: Option, parent_session_id: Option, title: SharedString, provisional_title: Option, @@ -1119,7 +1120,7 @@ impl AcpThread { pub fn new( parent_session_id: Option, title: impl Into, - cwd: Option, + work_dirs: Option, connection: Rc, project: Entity, action_log: Entity, @@ -1140,7 +1141,7 @@ impl AcpThread { Self { parent_session_id, - cwd, + work_dirs, action_log, shared_buffers: Default::default(), entries: Default::default(), @@ -1219,8 +1220,8 @@ impl AcpThread { &self.session_id } - pub fn cwd(&self) -> Option<&PathBuf> { - self.cwd.as_ref() + pub fn work_dirs(&self) -> Option<&PathList> { + self.work_dirs.as_ref() } pub fn status(&self) -> ThreadStatus { @@ -2858,7 +2859,7 @@ mod tests { use futures::{channel::mpsc, future::LocalBoxFuture, select}; use gpui::{App, AsyncApp, TestAppContext, WeakEntity}; use indoc::indoc; - use project::{FakeFs, Fs}; + use project::{AgentId, FakeFs, Fs}; use rand::{distr, prelude::*}; use serde_json::json; use settings::SettingsStore; @@ -2871,7 +2872,7 @@ mod tests { sync::atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst}, time::Duration, }; - use util::path; + use util::{path, path_list::PathList}; fn init_test(cx: &mut TestAppContext) { env_logger::try_init().ok(); @@ -2889,7 +2890,13 @@ mod tests { let project = Project::test(fs, [], cx).await; let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, std::path::Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session( + project, + PathList::new(&[std::path::Path::new(path!("/test"))]), + cx, + ) + }) .await .unwrap(); @@ -2953,7 +2960,13 @@ mod tests { let project = Project::test(fs, [], cx).await; let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, std::path::Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session( + project, + PathList::new(&[std::path::Path::new(path!("/test"))]), + cx, + ) + }) .await .unwrap(); @@ -3041,7 +3054,13 @@ mod tests { let project = Project::test(fs, [], cx).await; let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project.clone(), Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session( + project.clone(), + PathList::new(&[Path::new(path!("/test"))]), + cx, + ) + }) .await .unwrap(); @@ -3152,7 +3171,9 @@ mod tests { let project = Project::test(fs, [], cx).await; let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3246,7 +3267,9 @@ mod tests { )); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3327,7 +3350,9 @@ mod tests { .unwrap(); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx) + }) .await .unwrap(); @@ -3368,7 +3393,9 @@ mod tests { let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx) + }) .await .unwrap(); @@ -3443,7 +3470,9 @@ mod tests { let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx) + }) .await .unwrap(); @@ -3517,7 +3546,9 @@ mod tests { let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx) + }) .await .unwrap(); @@ -3565,7 +3596,9 @@ mod tests { })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3656,7 +3689,9 @@ mod tests { })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3715,7 +3750,9 @@ mod tests { } })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3888,7 +3925,9 @@ mod tests { })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3964,7 +4003,9 @@ mod tests { })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -4037,7 +4078,9 @@ mod tests { } })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -4158,6 +4201,10 @@ mod tests { } impl AgentConnection for FakeAgentConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("fake") + } + fn telemetry_id(&self) -> SharedString { "fake".into() } @@ -4169,7 +4216,7 @@ mod tests { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut App, ) -> Task>> { let session_id = acp::SessionId::new( @@ -4184,7 +4231,7 @@ mod tests { AcpThread::new( None, "Test", - Some(cwd.to_path_buf()), + Some(work_dirs), self.clone(), project, action_log, @@ -4283,7 +4330,9 @@ mod tests { let project = Project::test(fs, [], cx).await; let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -4349,7 +4398,9 @@ mod tests { let project = Project::test(fs, [], cx).await; let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -4662,7 +4713,9 @@ mod tests { )); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -4736,7 +4789,9 @@ mod tests { })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -4819,7 +4874,9 @@ mod tests { )); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -4867,7 +4924,9 @@ mod tests { let set_title_calls = connection.set_title_calls.clone(); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 4f6aaf86bad68f919c2c5de30214b21ff851c3dd..33692c90d7915b52d33764ce99f949ffab84e04e 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -5,17 +5,11 @@ use chrono::{DateTime, Utc}; use collections::IndexMap; use gpui::{Entity, SharedString, Task}; use language_model::LanguageModelProviderId; -use project::Project; +use project::{AgentId, Project}; use serde::{Deserialize, Serialize}; -use std::{ - any::Any, - error::Error, - fmt, - path::{Path, PathBuf}, - rc::Rc, - sync::Arc, -}; +use std::{any::Any, error::Error, fmt, path::PathBuf, rc::Rc, sync::Arc}; use ui::{App, IconName}; +use util::path_list::PathList; use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] @@ -28,12 +22,14 @@ impl UserMessageId { } pub trait AgentConnection { + fn agent_id(&self) -> AgentId; + fn telemetry_id(&self) -> SharedString; fn new_session( self: Rc, project: Entity, - cwd: &Path, + _work_dirs: PathList, cx: &mut App, ) -> Task>>; @@ -47,7 +43,7 @@ pub trait AgentConnection { self: Rc, _session_id: acp::SessionId, _project: Entity, - _cwd: &Path, + _work_dirs: PathList, _title: Option, _cx: &mut App, ) -> Task>> { @@ -78,7 +74,7 @@ pub trait AgentConnection { self: Rc, _session_id: acp::SessionId, _project: Entity, - _cwd: &Path, + _work_dirs: PathList, _title: Option, _cx: &mut App, ) -> Task>> { @@ -243,7 +239,7 @@ impl AgentSessionListResponse { #[derive(Debug, Clone, PartialEq)] pub struct AgentSessionInfo { pub session_id: acp::SessionId, - pub cwd: Option, + pub work_dirs: Option, pub title: Option, pub updated_at: Option>, pub created_at: Option>, @@ -254,7 +250,7 @@ impl AgentSessionInfo { pub fn new(session_id: impl Into) -> Self { Self { session_id: session_id.into(), - cwd: None, + work_dirs: None, title: None, updated_at: None, created_at: None, @@ -609,6 +605,10 @@ mod test_support { } impl AgentConnection for StubAgentConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("stub") + } + fn telemetry_id(&self) -> SharedString { "stub".into() } @@ -627,7 +627,7 @@ mod test_support { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { static NEXT_SESSION_ID: AtomicUsize = AtomicUsize::new(0); @@ -638,7 +638,7 @@ mod test_support { AcpThread::new( None, "Test", - Some(cwd.to_path_buf()), + Some(work_dirs), self.clone(), project, action_log, diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index b5b0e078ae0e41f5c3527265009fac803757ff1a..30d13effcb53395972879ef109a253be0c134ec1 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -14,7 +14,7 @@ use gpui::{ }; use language::LanguageRegistry; use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle}; -use project::Project; +use project::{AgentId, Project}; use settings::Settings; use theme::ThemeSettings; use ui::{CopyButton, Tooltip, WithScrollbar, prelude::*}; @@ -48,7 +48,7 @@ pub struct AcpConnectionRegistry { } struct ActiveConnection { - server_name: SharedString, + agent_id: AgentId, connection: Weak, } @@ -65,12 +65,12 @@ impl AcpConnectionRegistry { pub fn set_active_connection( &self, - server_name: impl Into, + agent_id: AgentId, connection: &Rc, cx: &mut Context, ) { self.active_connection.replace(Some(ActiveConnection { - server_name: server_name.into(), + agent_id, connection: Rc::downgrade(connection), })); cx.notify(); @@ -87,7 +87,7 @@ struct AcpTools { } struct WatchedConnection { - server_name: SharedString, + agent_id: AgentId, messages: Vec, list_state: ListState, connection: Weak, @@ -144,7 +144,7 @@ impl AcpTools { }); self.watched_connection = Some(WatchedConnection { - server_name: active_connection.server_name.clone(), + agent_id: active_connection.agent_id.clone(), messages: vec![], list_state: ListState::new(0, ListAlignment::Bottom, px(2048.)), connection: active_connection.connection.clone(), @@ -483,7 +483,7 @@ impl Item for AcpTools { "ACP: {}", self.watched_connection .as_ref() - .map_or("Disconnected", |connection| &connection.server_name) + .map_or("Disconnected", |connection| connection.agent_id.0.as_ref()) ) .into() } diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 2ac341dc997b016f3e723fad99a4a57007510c52..d4062fec85cb458ade372085d23fa42a47e631ed 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -41,7 +41,7 @@ use gpui::{ WeakEntity, }; use language_model::{IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelRegistry}; -use project::{Project, ProjectItem, ProjectPath, Worktree}; +use project::{AgentId, Project, ProjectItem, ProjectPath, Worktree}; use prompt_store::{ ProjectContext, PromptStore, RULES_FILE_NAMES, RulesFileContext, UserRulesContext, WorktreeContext, @@ -49,9 +49,9 @@ use prompt_store::{ use serde::{Deserialize, Serialize}; use settings::{LanguageModelSelection, update_settings_file}; use std::any::Any; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::rc::Rc; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use util::ResultExt; use util::path_list::PathList; use util::rel_path::RelPath; @@ -1381,7 +1381,13 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector { } } +pub static ZED_AGENT_ID: LazyLock = LazyLock::new(|| AgentId::new("Zed Agent")); + impl acp_thread::AgentConnection for NativeAgentConnection { + fn agent_id(&self) -> AgentId { + ZED_AGENT_ID.clone() + } + fn telemetry_id(&self) -> SharedString { "zed".into() } @@ -1389,10 +1395,10 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut App, ) -> Task>> { - log::debug!("Creating new thread for project at: {cwd:?}"); + log::debug!("Creating new thread for project at: {work_dirs:?}"); Task::ready(Ok(self .0 .update(cx, |agent, cx| agent.new_session(project, cx)))) @@ -1406,7 +1412,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { self: Rc, session_id: acp::SessionId, project: Entity, - _cwd: &Path, + _work_dirs: PathList, _title: Option, cx: &mut App, ) -> Task>> { @@ -2079,6 +2085,8 @@ impl TerminalHandle for AcpTerminalHandle { #[cfg(test)] mod internal_tests { + use std::path::Path; + use super::*; use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelInfo, MentionUri}; use fs::FakeFs; @@ -2111,7 +2119,13 @@ mod internal_tests { // Creating a session registers the project and triggers context building. let connection = NativeAgentConnection(agent.clone()); let _acp_thread = cx - .update(|cx| Rc::new(connection).new_session(project.clone(), Path::new("/"), cx)) + .update(|cx| { + Rc::new(connection).new_session( + project.clone(), + PathList::new(&[Path::new("/")]), + cx, + ) + }) .await .unwrap(); cx.run_until_parked(); @@ -2180,7 +2194,11 @@ mod internal_tests { // Create a thread/session let acp_thread = cx .update(|cx| { - Rc::new(connection.clone()).new_session(project.clone(), Path::new("/a"), cx) + Rc::new(connection.clone()).new_session( + project.clone(), + PathList::new(&[Path::new("/a")]), + cx, + ) }) .await .unwrap(); @@ -2251,7 +2269,11 @@ mod internal_tests { // Create a thread/session let acp_thread = cx .update(|cx| { - Rc::new(connection.clone()).new_session(project.clone(), Path::new("/a"), cx) + Rc::new(connection.clone()).new_session( + project.clone(), + PathList::new(&[Path::new("/a")]), + cx, + ) }) .await .unwrap(); @@ -2343,7 +2365,11 @@ mod internal_tests { let acp_thread = cx .update(|cx| { - Rc::new(connection.clone()).new_session(project.clone(), Path::new("/a"), cx) + Rc::new(connection.clone()).new_session( + project.clone(), + PathList::new(&[Path::new("/a")]), + cx, + ) }) .await .unwrap(); @@ -2450,9 +2476,11 @@ mod internal_tests { // Create a thread and select the thinking model. let acp_thread = cx .update(|cx| { - connection - .clone() - .new_session(project.clone(), Path::new("/a"), cx) + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new("/a")]), + cx, + ) }) .await .unwrap(); @@ -2552,9 +2580,11 @@ mod internal_tests { // Create a thread and select the model. let acp_thread = cx .update(|cx| { - connection - .clone() - .new_session(project.clone(), Path::new("/a"), cx) + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new("/a")]), + cx, + ) }) .await .unwrap(); @@ -2645,7 +2675,7 @@ mod internal_tests { .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index 43ab9c3c1826ea7d81fed2c934b96f3bb05dd519..bde07a040869bf11a1b95bf433bf6af1e2d0a932 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -25,11 +25,10 @@ pub type DbMessage = crate::Message; pub type DbSummary = crate::legacy_thread::DetailedSummaryState; pub type DbLanguageModel = crate::legacy_thread::SerializedLanguageModel; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub struct DbThreadMetadata { pub id: acp::SessionId, pub parent_session_id: Option, - #[serde(alias = "summary")] pub title: SharedString, pub updated_at: DateTime, pub created_at: Option>, @@ -42,7 +41,7 @@ impl From<&DbThreadMetadata> for acp_thread::AgentSessionInfo { fn from(meta: &DbThreadMetadata) -> Self { Self { session_id: meta.id.clone(), - cwd: None, + work_dirs: Some(meta.folder_paths.clone()), title: Some(meta.title.clone()), updated_at: Some(meta.updated_at), created_at: meta.created_at, @@ -881,7 +880,6 @@ mod tests { let threads = database.list_threads().await.unwrap(); assert_eq!(threads.len(), 1); - assert_eq!(threads[0].folder_paths, folder_paths); } #[gpui::test] @@ -901,7 +899,6 @@ mod tests { let threads = database.list_threads().await.unwrap(); assert_eq!(threads.len(), 1); - assert!(threads[0].folder_paths.is_empty()); } #[test] diff --git a/crates/agent/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs index ca5128fc80d49df0f165ab065a510585400f55d9..b2c3c913f19a877dcd001bd771809ce7f9a4afa5 100644 --- a/crates/agent/src/native_agent_server.rs +++ b/crates/agent/src/native_agent_server.rs @@ -6,7 +6,8 @@ use agent_settings::AgentSettings; use anyhow::Result; use collections::HashSet; use fs::Fs; -use gpui::{App, Entity, SharedString, Task}; +use gpui::{App, Entity, Task}; +use project::AgentId; use prompt_store::PromptStore; use settings::{LanguageModelSelection, Settings as _, update_settings_file}; @@ -25,8 +26,8 @@ impl NativeAgentServer { } impl AgentServer for NativeAgentServer { - fn name(&self) -> SharedString { - "Zed Agent".into() + fn agent_id(&self) -> AgentId { + crate::ZED_AGENT_ID.clone() } fn logo(&self) -> ui::IconName { diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index db3fa7c56ebc8ba7a94850d9d38b07c65a7ef4ba..e8a8acefce6d5728cd666d7fb7cb87ec3dcccb3e 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -3177,7 +3177,7 @@ async fn test_agent_connection(cx: &mut TestAppContext) { let fake_fs = cx.update(|cx| fs::FakeFs::new(cx.background_executor().clone())); fake_fs.insert_tree(path!("/test"), json!({})).await; let project = Project::test(fake_fs.clone(), [Path::new("/test")], cx).await; - let cwd = Path::new("/test"); + let cwd = PathList::new(&[Path::new("/test")]); let thread_store = cx.new(|cx| ThreadStore::new(cx)); // Create agent and connection @@ -4389,7 +4389,7 @@ async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) { .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); @@ -4524,7 +4524,7 @@ async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppCon .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); @@ -4672,7 +4672,7 @@ async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAp .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); @@ -4802,7 +4802,7 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) { .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); @@ -5174,7 +5174,7 @@ async fn test_subagent_context_window_warning(cx: &mut TestAppContext) { .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); @@ -5300,7 +5300,7 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); @@ -5474,7 +5474,7 @@ async fn test_subagent_error_propagation(cx: &mut TestAppContext) { .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index dd1f650de2f59a0e681e15e7eae3fad1a49ccc41..379ae675d4bbf3c2a9570365493317178f38a804 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -2,7 +2,6 @@ use crate::{DbThread, DbThreadMetadata, ThreadsDatabase}; use agent_client_protocol as acp; use anyhow::{Result, anyhow}; use gpui::{App, Context, Entity, Global, Task, prelude::*}; -use std::collections::HashMap; use util::path_list::PathList; struct GlobalThreadStore(Entity); @@ -11,7 +10,6 @@ impl Global for GlobalThreadStore {} pub struct ThreadStore { threads: Vec, - threads_by_paths: HashMap>, } impl ThreadStore { @@ -31,7 +29,6 @@ impl ThreadStore { pub fn new(cx: &mut Context) -> Self { let this = Self { threads: Vec::new(), - threads_by_paths: HashMap::default(), }; this.reload(cx); this @@ -97,16 +94,10 @@ impl ThreadStore { let all_threads = database.list_threads().await?; this.update(cx, |this, cx| { this.threads.clear(); - this.threads_by_paths.clear(); for thread in all_threads { if thread.parent_session_id.is_some() { continue; } - let index = this.threads.len(); - this.threads_by_paths - .entry(thread.folder_paths.clone()) - .or_default() - .push(index); this.threads.push(thread); } cx.notify(); @@ -122,15 +113,6 @@ impl ThreadStore { pub fn entries(&self) -> impl Iterator + '_ { self.threads.iter().cloned() } - - /// Returns threads whose folder_paths match the given paths exactly. - /// Uses a cached index for O(1) lookup per path list. - pub fn threads_for_paths(&self, paths: &PathList) -> impl Iterator { - self.threads_by_paths - .get(paths) - .into_iter() - .flat_map(|indices| indices.iter().map(|&index| &self.threads[index])) - } } #[cfg(test)] @@ -306,50 +288,4 @@ mod tests { assert_eq!(entries[0].id, first_id); assert_eq!(entries[1].id, second_id); } - - #[gpui::test] - async fn test_threads_for_paths_filters_correctly(cx: &mut TestAppContext) { - let thread_store = cx.new(|cx| ThreadStore::new(cx)); - cx.run_until_parked(); - - let project_a_paths = PathList::new(&[std::path::PathBuf::from("/home/user/project-a")]); - let project_b_paths = PathList::new(&[std::path::PathBuf::from("/home/user/project-b")]); - - let thread_a = make_thread( - "Thread in A", - Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), - ); - let thread_b = make_thread( - "Thread in B", - Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap(), - ); - let thread_a_id = session_id("thread-a"); - let thread_b_id = session_id("thread-b"); - - let save_a = thread_store.update(cx, |store, cx| { - store.save_thread(thread_a_id.clone(), thread_a, project_a_paths.clone(), cx) - }); - save_a.await.unwrap(); - - let save_b = thread_store.update(cx, |store, cx| { - store.save_thread(thread_b_id.clone(), thread_b, project_b_paths.clone(), cx) - }); - save_b.await.unwrap(); - - cx.run_until_parked(); - - thread_store.read_with(cx, |store, _cx| { - let a_threads: Vec<_> = store.threads_for_paths(&project_a_paths).collect(); - assert_eq!(a_threads.len(), 1); - assert_eq!(a_threads[0].id, thread_a_id); - - let b_threads: Vec<_> = store.threads_for_paths(&project_b_paths).collect(); - assert_eq!(b_threads.len(), 1); - assert_eq!(b_threads[0].id, thread_b_id); - - let nonexistent = PathList::new(&[std::path::PathBuf::from("/nonexistent")]); - let no_threads: Vec<_> = store.threads_for_paths(&nonexistent).collect(); - assert!(no_threads.is_empty()); - }); - } } diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 8f7f7c94535453f0c2c0598de2b86bf51cd79a9d..54166f1d553b4ed1ef0f3642517125b30bc5fda8 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -9,18 +9,19 @@ use anyhow::anyhow; use collections::HashMap; use futures::AsyncBufReadExt as _; use futures::io::BufReader; -use project::Project; -use project::agent_server_store::{AgentServerCommand, GEMINI_NAME}; +use project::agent_server_store::{AgentServerCommand, GEMINI_ID}; +use project::{AgentId, Project}; use serde::Deserialize; use settings::Settings as _; use task::ShellBuilder; use util::ResultExt as _; +use util::path_list::PathList; use util::process::Child; use std::path::PathBuf; use std::process::Stdio; +use std::rc::Rc; use std::{any::Any, cell::RefCell}; -use std::{path::Path, rc::Rc}; use thiserror::Error; use anyhow::{Context as _, Result}; @@ -35,7 +36,7 @@ use terminal::terminal_settings::{AlternateScroll, CursorShape, TerminalSettings pub struct UnsupportedVersion; pub struct AcpConnection { - server_name: SharedString, + id: AgentId, display_name: SharedString, telemetry_id: SharedString, connection: Rc, @@ -124,7 +125,7 @@ impl AgentSessionList for AcpSessionList { .into_iter() .map(|s| AgentSessionInfo { session_id: s.session_id, - cwd: Some(s.cwd), + work_dirs: Some(PathList::new(&[s.cwd])), title: s.title.map(Into::into), updated_at: s.updated_at.and_then(|date_str| { chrono::DateTime::parse_from_rfc3339(&date_str) @@ -158,7 +159,7 @@ impl AgentSessionList for AcpSessionList { } pub async fn connect( - server_name: SharedString, + agent_id: AgentId, display_name: SharedString, command: AgentServerCommand, default_mode: Option, @@ -167,7 +168,7 @@ pub async fn connect( cx: &mut AsyncApp, ) -> Result> { let conn = AcpConnection::stdio( - server_name, + agent_id, display_name, command.clone(), default_mode, @@ -183,7 +184,7 @@ const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::ProtocolVersion::V1 impl AcpConnection { pub async fn stdio( - server_name: SharedString, + agent_id: AgentId, display_name: SharedString, command: AgentServerCommand, default_mode: Option, @@ -270,7 +271,7 @@ impl AcpConnection { cx.update(|cx| { AcpConnectionRegistry::default_global(cx).update(cx, |registry, cx| { - registry.set_active_connection(server_name.clone(), &connection, cx) + registry.set_active_connection(agent_id.clone(), &connection, cx) }); }); @@ -305,7 +306,7 @@ impl AcpConnection { // Use the one the agent provides if we have one .map(|info| info.name.into()) // Otherwise, just use the name - .unwrap_or_else(|| server_name.clone()); + .unwrap_or_else(|| agent_id.0.to_string().into()); let session_list = if response .agent_capabilities @@ -321,7 +322,7 @@ impl AcpConnection { }; // TODO: Remove this override once Google team releases their official auth methods - let auth_methods = if server_name == GEMINI_NAME { + let auth_methods = if agent_id.0.as_ref() == GEMINI_ID { let mut args = command.args.clone(); args.retain(|a| a != "--experimental-acp"); let value = serde_json::json!({ @@ -340,9 +341,9 @@ impl AcpConnection { response.auth_methods }; Ok(Self { + id: agent_id, auth_methods, connection, - server_name, display_name, telemetry_id, sessions, @@ -368,7 +369,7 @@ impl AcpConnection { config_options: &Rc>>, cx: &mut AsyncApp, ) { - let name = self.server_name.clone(); + let id = self.id.clone(); let defaults_to_apply: Vec<_> = { let config_opts_ref = config_options.borrow(); config_opts_ref @@ -410,7 +411,7 @@ impl AcpConnection { "`{}` is not a valid value for config option `{}` in {}", default_value, config_option.id.0, - name + id ); None } @@ -466,6 +467,10 @@ impl Drop for AcpConnection { } impl AgentConnection for AcpConnection { + fn agent_id(&self) -> AgentId { + self.id.clone() + } + fn telemetry_id(&self) -> SharedString { self.telemetry_id.clone() } @@ -473,11 +478,14 @@ impl AgentConnection for AcpConnection { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut App, ) -> Task>> { - let name = self.server_name.clone(); - let cwd = cwd.to_path_buf(); + // TODO: remove this once ACP supports multiple working directories + let Some(cwd) = work_dirs.ordered_paths().next().cloned() else { + return Task::ready(Err(anyhow!("Working directory cannot be empty"))); + }; + let name = self.id.0.clone(); let mcp_servers = mcp_servers_for_project(&project, cx); cx.spawn(async move |cx| { @@ -575,7 +583,7 @@ impl AgentConnection for AcpConnection { AcpThread::new( None, self.display_name.clone(), - Some(cwd), + Some(work_dirs), self.clone(), project, action_log, @@ -616,7 +624,7 @@ impl AgentConnection for AcpConnection { self: Rc, session_id: acp::SessionId, project: Entity, - cwd: &Path, + work_dirs: PathList, title: Option, cx: &mut App, ) -> Task>> { @@ -625,8 +633,11 @@ impl AgentConnection for AcpConnection { "Loading sessions is not supported by this agent.".into() )))); } + // TODO: remove this once ACP supports multiple working directories + let Some(cwd) = work_dirs.ordered_paths().next().cloned() else { + return Task::ready(Err(anyhow!("Working directory cannot be empty"))); + }; - let cwd = cwd.to_path_buf(); let mcp_servers = mcp_servers_for_project(&project, cx); let action_log = cx.new(|_| ActionLog::new(project.clone())); let title = title.unwrap_or_else(|| self.display_name.clone()); @@ -634,7 +645,7 @@ impl AgentConnection for AcpConnection { AcpThread::new( None, title, - Some(cwd.clone()), + Some(work_dirs.clone()), self.clone(), project, action_log, @@ -691,7 +702,7 @@ impl AgentConnection for AcpConnection { self: Rc, session_id: acp::SessionId, project: Entity, - cwd: &Path, + work_dirs: PathList, title: Option, cx: &mut App, ) -> Task>> { @@ -705,8 +716,11 @@ impl AgentConnection for AcpConnection { "Resuming sessions is not supported by this agent.".into() )))); } + // TODO: remove this once ACP supports multiple working directories + let Some(cwd) = work_dirs.ordered_paths().next().cloned() else { + return Task::ready(Err(anyhow!("Working directory cannot be empty"))); + }; - let cwd = cwd.to_path_buf(); let mcp_servers = mcp_servers_for_project(&project, cx); let action_log = cx.new(|_| ActionLog::new(project.clone())); let title = title.unwrap_or_else(|| self.display_name.clone()); @@ -714,7 +728,7 @@ impl AgentConnection for AcpConnection { AcpThread::new( None, title, - Some(cwd.clone()), + Some(work_dirs), self.clone(), project, action_log, diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index a12b63164325cfc447e44b3a5899e79b774e141f..020e36b999e3586430ae99b12af55a845de91cb8 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -9,11 +9,11 @@ use collections::{HashMap, HashSet}; pub use custom::*; use fs::Fs; use http_client::read_no_proxy_from_env; -use project::agent_server_store::AgentServerStore; +use project::{AgentId, agent_server_store::AgentServerStore}; use acp_thread::AgentConnection; use anyhow::Result; -use gpui::{App, AppContext, Entity, SharedString, Task}; +use gpui::{App, AppContext, Entity, Task}; use settings::SettingsStore; use std::{any::Any, rc::Rc, sync::Arc}; @@ -38,7 +38,7 @@ impl AgentServerDelegate { pub trait AgentServer: Send { fn logo(&self) -> ui::IconName; - fn name(&self) -> SharedString; + fn agent_id(&self) -> AgentId; fn connect( &self, delegate: AgentServerDelegate, diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index d87b9dc4ece042d94da6e6e0ac99e1474c1ce018..d9a4469aefa957033a583a1061656dcb090eeec1 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -5,10 +5,10 @@ use anyhow::{Context as _, Result}; use collections::HashSet; use credentials_provider::CredentialsProvider; use fs::Fs; -use gpui::{App, AppContext as _, SharedString, Task}; +use gpui::{App, AppContext as _, Task}; use language_model::{ApiKey, EnvVar}; use project::agent_server_store::{ - AllAgentServersSettings, CLAUDE_AGENT_NAME, CODEX_NAME, ExternalAgentServerName, GEMINI_NAME, + AgentId, AllAgentServersSettings, CLAUDE_AGENT_ID, CODEX_ID, GEMINI_ID, }; use settings::{SettingsStore, update_settings_file}; use std::{rc::Rc, sync::Arc}; @@ -16,18 +16,18 @@ use ui::IconName; /// A generic agent server implementation for custom user-defined agents pub struct CustomAgentServer { - name: SharedString, + agent_id: AgentId, } impl CustomAgentServer { - pub fn new(name: SharedString) -> Self { - Self { name } + pub fn new(agent_id: AgentId) -> Self { + Self { agent_id } } } impl AgentServer for CustomAgentServer { - fn name(&self) -> SharedString { - self.name.clone() + fn agent_id(&self) -> AgentId { + self.agent_id.clone() } fn logo(&self) -> IconName { @@ -38,7 +38,7 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .get(self.name().as_ref()) + .get(self.agent_id().0.as_ref()) .cloned() }); @@ -55,7 +55,7 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .get(self.name().as_ref()) + .get(self.agent_id().0.as_ref()) .cloned() }); @@ -80,7 +80,7 @@ impl AgentServer for CustomAgentServer { fs: Arc, cx: &App, ) { - let name = self.name(); + let agent_id = self.agent_id(); let config_id = config_id.to_string(); let value_id = value_id.to_string(); @@ -88,8 +88,8 @@ impl AgentServer for CustomAgentServer { let settings = settings .agent_servers .get_or_insert_default() - .entry(name.to_string()) - .or_insert_with(|| default_settings_for_agent(&name, cx)); + .entry(agent_id.0.to_string()) + .or_insert_with(|| default_settings_for_agent(agent_id, cx)); match settings { settings::CustomAgentServerSettings::Custom { @@ -124,13 +124,13 @@ impl AgentServer for CustomAgentServer { } fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { - let name = self.name(); + let agent_id = self.agent_id(); update_settings_file(fs, cx, move |settings, cx| { let settings = settings .agent_servers .get_or_insert_default() - .entry(name.to_string()) - .or_insert_with(|| default_settings_for_agent(&name, cx)); + .entry(agent_id.0.to_string()) + .or_insert_with(|| default_settings_for_agent(agent_id, cx)); match settings { settings::CustomAgentServerSettings::Custom { default_mode, .. } @@ -146,7 +146,7 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .get(self.name().as_ref()) + .get(self.agent_id().as_ref()) .cloned() }); @@ -156,13 +156,13 @@ impl AgentServer for CustomAgentServer { } fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { - let name = self.name(); + let agent_id = self.agent_id(); update_settings_file(fs, cx, move |settings, cx| { let settings = settings .agent_servers .get_or_insert_default() - .entry(name.to_string()) - .or_insert_with(|| default_settings_for_agent(&name, cx)); + .entry(agent_id.0.to_string()) + .or_insert_with(|| default_settings_for_agent(agent_id, cx)); match settings { settings::CustomAgentServerSettings::Custom { default_model, .. } @@ -178,7 +178,7 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .get(self.name().as_ref()) + .get(self.agent_id().as_ref()) .cloned() }); @@ -200,13 +200,13 @@ impl AgentServer for CustomAgentServer { fs: Arc, cx: &App, ) { - let name = self.name(); + let agent_id = self.agent_id(); update_settings_file(fs, cx, move |settings, cx| { let settings = settings .agent_servers .get_or_insert_default() - .entry(name.to_string()) - .or_insert_with(|| default_settings_for_agent(&name, cx)); + .entry(agent_id.0.to_string()) + .or_insert_with(|| default_settings_for_agent(agent_id, cx)); let favorite_models = match settings { settings::CustomAgentServerSettings::Custom { @@ -235,7 +235,7 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .get(self.name().as_ref()) + .get(self.agent_id().as_ref()) .cloned() }); @@ -251,15 +251,15 @@ impl AgentServer for CustomAgentServer { fs: Arc, cx: &mut App, ) { - let name = self.name(); + let agent_id = self.agent_id(); let config_id = config_id.to_string(); let value_id = value_id.map(|s| s.to_string()); update_settings_file(fs, cx, move |settings, cx| { let settings = settings .agent_servers .get_or_insert_default() - .entry(name.to_string()) - .or_insert_with(|| default_settings_for_agent(&name, cx)); + .entry(agent_id.0.to_string()) + .or_insert_with(|| default_settings_for_agent(agent_id, cx)); match settings { settings::CustomAgentServerSettings::Custom { @@ -289,19 +289,19 @@ impl AgentServer for CustomAgentServer { delegate: AgentServerDelegate, cx: &mut App, ) -> Task>> { - let name = self.name(); + let agent_id = self.agent_id(); let display_name = delegate .store .read(cx) - .agent_display_name(&ExternalAgentServerName(name.clone())) - .unwrap_or_else(|| name.clone()); + .agent_display_name(&agent_id) + .unwrap_or_else(|| agent_id.0.clone()); let default_mode = self.default_mode(cx); let default_model = self.default_model(cx); - let is_registry_agent = is_registry_agent(&name, cx); + let is_registry_agent = is_registry_agent(agent_id.clone(), cx); let default_config_options = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .get(self.name().as_ref()) + .get(self.agent_id().as_ref()) .map(|s| match s { project::agent_server_store::CustomAgentServerSettings::Custom { default_config_options, @@ -330,11 +330,11 @@ impl AgentServer for CustomAgentServer { extra_env.insert("NO_BROWSER".to_owned(), "1".to_owned()); } if is_registry_agent { - match name.as_ref() { - CLAUDE_AGENT_NAME => { + match agent_id.as_ref() { + CLAUDE_AGENT_ID => { extra_env.insert("ANTHROPIC_API_KEY".into(), "".into()); } - CODEX_NAME => { + CODEX_ID => { if let Ok(api_key) = std::env::var("CODEX_API_KEY") { extra_env.insert("CODEX_API_KEY".into(), api_key); } @@ -342,7 +342,7 @@ impl AgentServer for CustomAgentServer { extra_env.insert("OPEN_AI_API_KEY".into(), api_key); } } - GEMINI_NAME => { + GEMINI_ID => { extra_env.insert("SURFACE".to_owned(), "zed".to_owned()); } _ => {} @@ -350,18 +350,16 @@ impl AgentServer for CustomAgentServer { } let store = delegate.store.downgrade(); cx.spawn(async move |cx| { - if is_registry_agent && name.as_ref() == GEMINI_NAME { + if is_registry_agent && agent_id.as_ref() == GEMINI_ID { if let Some(api_key) = cx.update(api_key_for_gemini_cli).await.ok() { extra_env.insert("GEMINI_API_KEY".into(), api_key); } } let command = store .update(cx, |store, cx| { - let agent = store - .get_external_agent(&ExternalAgentServerName(name.clone())) - .with_context(|| { - format!("Custom agent server `{}` is not registered", name) - })?; + let agent = store.get_external_agent(&agent_id).with_context(|| { + format!("Custom agent server `{}` is not registered", agent_id) + })?; anyhow::Ok(agent.get_command( extra_env, delegate.new_version_available, @@ -370,7 +368,7 @@ impl AgentServer for CustomAgentServer { })?? .await?; let connection = crate::acp::connect( - name, + agent_id, display_name, command, default_mode, @@ -405,15 +403,17 @@ fn api_key_for_gemini_cli(cx: &mut App) -> Task> { }) } -fn is_registry_agent(name: &str, cx: &App) -> bool { - let is_previous_built_in = matches!(name, CLAUDE_AGENT_NAME | CODEX_NAME | GEMINI_NAME); +fn is_registry_agent(agent_id: impl Into, cx: &App) -> bool { + let agent_id = agent_id.into(); + let is_previous_built_in = + matches!(agent_id.0.as_ref(), CLAUDE_AGENT_ID | CODEX_ID | GEMINI_ID); let is_in_registry = project::AgentRegistryStore::try_global(cx) - .map(|store| store.read(cx).agent(name).is_some()) + .map(|store| store.read(cx).agent(&agent_id).is_some()) .unwrap_or(false); let is_settings_registry = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .get(name) + .get(agent_id.as_ref()) .is_some_and(|s| { matches!( s, @@ -424,8 +424,11 @@ fn is_registry_agent(name: &str, cx: &App) -> bool { is_previous_built_in || is_in_registry || is_settings_registry } -fn default_settings_for_agent(name: &str, cx: &App) -> settings::CustomAgentServerSettings { - if is_registry_agent(name, cx) { +fn default_settings_for_agent( + agent_id: impl Into, + cx: &App, +) -> settings::CustomAgentServerSettings { + if is_registry_agent(agent_id, cx) { settings::CustomAgentServerSettings::Registry { default_model: None, default_mode: None, @@ -455,6 +458,7 @@ mod tests { AgentRegistryStore, RegistryAgent, RegistryAgentMetadata, RegistryNpxAgent, }; use settings::Settings as _; + use ui::SharedString; fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { @@ -470,7 +474,7 @@ mod tests { let id = SharedString::from(id.to_string()); RegistryAgent::Npx(RegistryNpxAgent { metadata: RegistryAgentMetadata { - id: id.clone(), + id: AgentId::new(id.clone()), name: id.clone(), description: SharedString::from(""), version: SharedString::from("1.0.0"), @@ -509,9 +513,9 @@ mod tests { fn test_previous_builtins_are_registry(cx: &mut TestAppContext) { init_test(cx); cx.update(|cx| { - assert!(is_registry_agent(CLAUDE_AGENT_NAME, cx)); - assert!(is_registry_agent(CODEX_NAME, cx)); - assert!(is_registry_agent(GEMINI_NAME, cx)); + assert!(is_registry_agent(CLAUDE_AGENT_ID, cx)); + assert!(is_registry_agent(CODEX_ID, cx)); + assert!(is_registry_agent(GEMINI_ID, cx)); }); } @@ -582,15 +586,15 @@ mod tests { init_test(cx); cx.update(|cx| { assert!(matches!( - default_settings_for_agent(CODEX_NAME, cx), + default_settings_for_agent(CODEX_ID, cx), settings::CustomAgentServerSettings::Registry { .. } )); assert!(matches!( - default_settings_for_agent(CLAUDE_AGENT_NAME, cx), + default_settings_for_agent(CLAUDE_AGENT_ID, cx), settings::CustomAgentServerSettings::Registry { .. } )); assert!(matches!( - default_settings_for_agent(GEMINI_NAME, cx), + default_settings_for_agent(GEMINI_ID, cx), settings::CustomAgentServerSettings::Registry { .. } )); }); diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 5dcf416bb87ba4812e1a828c23d49819f2874a99..b9365296c3fdb9ed7dc45c1c146d0abd7a831fce 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -14,6 +14,7 @@ use std::{ time::Duration, }; use util::path; +use util::path_list::PathList; pub async fn test_basic(server: F, cx: &mut TestAppContext) where @@ -435,9 +436,11 @@ pub async fn new_test_thread( let connection = cx.update(|cx| server.connect(delegate, cx)).await.unwrap(); - cx.update(|cx| connection.new_session(project.clone(), current_dir.as_ref(), cx)) - .await - .unwrap() + cx.update(|cx| { + connection.new_session(project.clone(), PathList::new(&[current_dir.as_ref()]), cx) + }) + .await + .unwrap() } pub async fn run_until_first_tool_call( diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 6b7f46d87f2db1e9262eadf9e7064c06245b1e3c..7c2f23fcbce43bed271c58b750145d75655d16ba 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -28,7 +28,7 @@ use language_model::{ use language_models::AllLanguageModelSettings; use notifications::status_toast::{StatusToast, ToastIcon}; use project::{ - agent_server_store::{AgentServerStore, ExternalAgentServerName, ExternalAgentSource}, + agent_server_store::{AgentId, AgentServerStore, ExternalAgentSource}, context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, }; use settings::{Settings, SettingsStore, update_settings_file}; @@ -1103,7 +1103,7 @@ impl AgentConfiguration { ExternalAgentSource::Custom => None, }; - let agent_server_name = ExternalAgentServerName(id.clone()); + let agent_server_name = AgentId(id.clone()); let uninstall_button = match source { ExternalAgentSource::Extension => Some( diff --git a/crates/agent_ui/src/agent_connection_store.rs b/crates/agent_ui/src/agent_connection_store.rs index c9be46aea3ad99dec77724710db9088ae459696e..79644eb26886c4ea23b9440473193a8f15bec977 100644 --- a/crates/agent_ui/src/agent_connection_store.rs +++ b/crates/agent_ui/src/agent_connection_store.rs @@ -10,7 +10,6 @@ use project::{AgentServerStore, AgentServersUpdated, Project}; use watch::Receiver; use crate::{Agent, ThreadHistory}; -use project::ExternalAgentServerName; pub enum AgentConnectionEntry { Connecting { @@ -143,9 +142,7 @@ impl AgentConnectionStore { let store = store.read(cx); self.entries.retain(|key, _| match key { Agent::NativeAgent => true, - Agent::Custom { name } => store - .external_agents - .contains_key(&ExternalAgentServerName(name.clone())), + Agent::Custom { id } => store.external_agents.contains_key(id), }); cx.notify(); } diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index bb1367b7da31d7975ab271ec821fb43a5da70605..a44546fb2bfdfe4800d8087a6370635c6e96de9e 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1805,7 +1805,7 @@ mod tests { use settings::SettingsStore; use std::{path::Path, rc::Rc}; use util::path; - use workspace::MultiWorkspace; + use workspace::{MultiWorkspace, PathList}; #[gpui::test] async fn test_multibuffer_agent_diff(cx: &mut TestAppContext) { @@ -1833,9 +1833,11 @@ mod tests { let connection = Rc::new(acp_thread::StubAgentConnection::new()); let thread = cx .update(|cx| { - connection - .clone() - .new_session(project.clone(), Path::new(path!("/test")), cx) + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new(path!("/test"))]), + cx, + ) }) .await .unwrap(); @@ -2024,9 +2026,11 @@ mod tests { let connection = Rc::new(acp_thread::StubAgentConnection::new()); let thread = cx .update(|_, cx| { - connection - .clone() - .new_session(project.clone(), Path::new(path!("/test")), cx) + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new(path!("/test"))]), + cx, + ) }) .await .unwrap(); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 10d24e61fe3e6bbf5d0a0d88e0f28ba3fbfa2b78..ccf9e481ef48095de76587c915962eef458a77e1 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -17,8 +17,8 @@ use collections::HashSet; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use itertools::Itertools; use project::{ - ExternalAgentServerName, - agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME}, + AgentId, + agent_server_store::{CLAUDE_AGENT_ID, CODEX_ID, GEMINI_ID}, }; use serde::{Deserialize, Serialize}; use settings::{LanguageModelProviderSetting, LanguageModelSelection}; @@ -86,8 +86,8 @@ use ui::{ use util::{ResultExt as _, debug_panic}; use workspace::{ CollaboratorId, DraggedSelection, DraggedSidebar, DraggedTab, FocusWorkspaceSidebar, - MultiWorkspace, OpenResult, SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, ToggleZoom, - ToolbarItemView, Workspace, WorkspaceId, + MultiWorkspace, OpenResult, PathList, SIDEBAR_RESIZE_HANDLE_SIZE, SerializedPathList, + ToggleWorkspaceSidebar, ToggleZoom, ToolbarItemView, Workspace, WorkspaceId, dock::{DockPosition, Panel, PanelEvent}, multi_workspace_enabled, }; @@ -180,7 +180,7 @@ fn read_legacy_serialized_panel() -> Option { .and_then(|json| serde_json::from_str::(&json).log_err()) } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug)] struct SerializedAgentPanel { width: Option, selected_agent: Option, @@ -190,12 +190,12 @@ struct SerializedAgentPanel { start_thread_in: Option, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug)] struct SerializedActiveThread { session_id: String, agent_type: AgentType, title: Option, - cwd: Option, + work_dirs: Option, } pub fn init(cx: &mut App) { @@ -651,7 +651,8 @@ pub enum AgentType { NativeAgent, TextThread, Custom { - name: SharedString, + #[serde(rename = "name")] + id: AgentId, }, } @@ -671,13 +672,13 @@ impl<'de> Deserialize<'de> for AgentType { "NativeAgent" => Ok(Self::NativeAgent), "TextThread" => Ok(Self::TextThread), "ClaudeAgent" | "ClaudeCode" => Ok(Self::Custom { - name: CLAUDE_AGENT_NAME.into(), + id: CLAUDE_AGENT_ID.into(), }), "Codex" => Ok(Self::Custom { - name: CODEX_NAME.into(), + id: CODEX_ID.into(), }), "Gemini" => Ok(Self::Custom { - name: GEMINI_NAME.into(), + id: GEMINI_ID.into(), }), other => Err(serde::de::Error::unknown_variant( other, @@ -702,7 +703,9 @@ impl<'de> Deserialize<'de> for AgentType { } let fields: CustomFields = serde_json::from_value(inner.clone()).map_err(serde::de::Error::custom)?; - return Ok(Self::Custom { name: fields.name }); + return Ok(Self::Custom { + id: AgentId::new(fields.name), + }); } } @@ -720,7 +723,7 @@ impl AgentType { fn label(&self) -> SharedString { match self { Self::NativeAgent | Self::TextThread => "Zed Agent".into(), - Self::Custom { name, .. } => name.into(), + Self::Custom { id, .. } => id.0.clone(), } } @@ -735,7 +738,7 @@ impl AgentType { impl From for AgentType { fn from(value: Agent) -> Self { match value { - Agent::Custom { name } => Self::Custom { name }, + Agent::Custom { id } => Self::Custom { id }, Agent::NativeAgent => Self::NativeAgent, } } @@ -913,6 +916,7 @@ impl AgentPanel { let last_active_thread = self.active_agent_thread(cx).map(|thread| { let thread = thread.read(cx); let title = thread.title(); + let work_dirs = thread.work_dirs().cloned(); SerializedActiveThread { session_id: thread.session_id().0.to_string(), agent_type: self.selected_agent_type.clone(), @@ -921,7 +925,7 @@ impl AgentPanel { } else { None }, - cwd: None, + work_dirs: work_dirs.map(|dirs| dirs.serialize()), } }); @@ -979,7 +983,7 @@ impl AgentPanel { let last_active_thread = if let Some(thread_info) = serialized_panel .as_ref() - .and_then(|p| p.last_active_thread.clone()) + .and_then(|p| p.last_active_thread.as_ref()) { if thread_info.agent_type.is_native() { let session_id = acp::SessionId::new(thread_info.session_id.clone()); @@ -1048,9 +1052,9 @@ impl AgentPanel { if let Some(agent) = panel.selected_agent() { panel.load_agent_thread( agent, - thread_info.session_id.into(), - thread_info.cwd, - thread_info.title.map(SharedString::from), + thread_info.session_id.clone().into(), + thread_info.work_dirs.as_ref().map(|dirs| PathList::deserialize(dirs)), + thread_info.title.as_ref().map(|t| t.clone().into()), false, window, cx, @@ -1292,7 +1296,7 @@ impl AgentPanel { pub fn open_thread( &mut self, session_id: acp::SessionId, - cwd: Option, + work_dirs: Option, title: Option, window: &mut Window, cx: &mut Context, @@ -1300,7 +1304,7 @@ impl AgentPanel { self.external_thread( Some(crate::Agent::NativeAgent), Some(session_id), - cwd, + work_dirs, title, None, true, @@ -1435,7 +1439,7 @@ impl AgentPanel { &mut self, agent_choice: Option, resume_session_id: Option, - cwd: Option, + work_dirs: Option, title: Option, initial_content: Option, focus: bool, @@ -1476,7 +1480,7 @@ impl AgentPanel { self.create_agent_thread( server, resume_session_id, - cwd, + work_dirs, title, initial_content, workspace, @@ -1509,7 +1513,7 @@ impl AgentPanel { agent_panel.create_agent_thread( server, resume_session_id, - cwd, + work_dirs, title, initial_content, workspace, @@ -1569,8 +1573,8 @@ impl AgentPanel { fn has_history_for_selected_agent(&self, cx: &App) -> bool { match &self.selected_agent_type { AgentType::TextThread | AgentType::NativeAgent => true, - AgentType::Custom { name } => { - let agent = Agent::Custom { name: name.clone() }; + AgentType::Custom { id } => { + let agent = Agent::Custom { id: id.clone() }; self.connection_store .read(cx) .entry(&agent) @@ -1599,8 +1603,8 @@ impl AgentPanel { view: self.create_thread_history_view(Agent::NativeAgent, history, window, cx), }) } - AgentType::Custom { name } => { - let agent = Agent::Custom { name: name.clone() }; + AgentType::Custom { id, .. } => { + let agent = Agent::Custom { id: id.clone() }; let history = self .connection_store .read(cx) @@ -1635,7 +1639,7 @@ impl AgentPanel { this.load_agent_thread( agent.clone(), thread.session_id.clone(), - thread.cwd.clone(), + thread.work_dirs.clone(), thread.title.clone(), true, window, @@ -2286,7 +2290,7 @@ impl AgentPanel { this.load_agent_thread( agent, entry.session_id.clone(), - entry.cwd.clone(), + entry.work_dirs.clone(), entry.title.clone(), true, window, @@ -2415,7 +2419,7 @@ impl AgentPanel { pub(crate) fn selected_agent(&self) -> Option { match &self.selected_agent_type { AgentType::NativeAgent => Some(Agent::NativeAgent), - AgentType::Custom { name } => Some(Agent::Custom { name: name.clone() }), + AgentType::Custom { id } => Some(Agent::Custom { id: id.clone() }), AgentType::TextThread => None, } } @@ -2494,8 +2498,8 @@ impl AgentPanel { window, cx, ), - AgentType::Custom { name } => self.external_thread( - Some(crate::Agent::Custom { name }), + AgentType::Custom { id } => self.external_thread( + Some(crate::Agent::Custom { id }), None, None, None, @@ -2511,7 +2515,7 @@ impl AgentPanel { &mut self, agent: Agent, session_id: acp::SessionId, - cwd: Option, + work_dirs: Option, title: Option, focus: bool, window: &mut Window, @@ -2550,7 +2554,7 @@ impl AgentPanel { self.external_thread( Some(agent), Some(session_id), - cwd, + work_dirs, title, None, focus, @@ -2563,7 +2567,7 @@ impl AgentPanel { &mut self, server: Rc, resume_session_id: Option, - cwd: Option, + work_dirs: Option, title: Option, initial_content: Option, workspace: WeakEntity, @@ -2592,7 +2596,7 @@ impl AgentPanel { connection_store, ext_agent, resume_session_id, - cwd, + work_dirs, title, initial_content, workspace.clone(), @@ -3871,12 +3875,12 @@ impl AgentPanel { let docked_right = agent_panel_dock_position(cx) == DockPosition::Right; let (selected_agent_custom_icon, selected_agent_label) = - if let AgentType::Custom { name, .. } = &self.selected_agent_type { + if let AgentType::Custom { id, .. } = &self.selected_agent_type { let store = agent_server_store.read(cx); - let icon = store.agent_icon(&ExternalAgentServerName(name.clone())); + let icon = store.agent_icon(&id); let label = store - .agent_display_name(&ExternalAgentServerName(name.clone())) + .agent_display_name(&id) .unwrap_or_else(|| self.selected_agent_type.label()); (icon, label) } else { @@ -4005,24 +4009,24 @@ impl AgentPanel { registry_store.as_ref().map(|s| s.read(cx)); struct AgentMenuItem { - id: ExternalAgentServerName, + id: AgentId, display_name: SharedString, } let agent_items = agent_server_store .external_agents() - .map(|name| { + .map(|agent_id| { let display_name = agent_server_store - .agent_display_name(name) + .agent_display_name(agent_id) .or_else(|| { registry_store_ref .as_ref() - .and_then(|store| store.agent(name.0.as_ref())) + .and_then(|store| store.agent(agent_id)) .map(|a| a.name().clone()) }) - .unwrap_or_else(|| name.0.clone()); + .unwrap_or_else(|| agent_id.0.clone()); AgentMenuItem { - id: name.clone(), + id: agent_id.clone(), display_name, } }) @@ -4038,7 +4042,7 @@ impl AgentPanel { .or_else(|| { registry_store_ref .as_ref() - .and_then(|store| store.agent(item.id.0.as_str())) + .and_then(|store| store.agent(&item.id)) .and_then(|a| a.icon_path().cloned()) }); @@ -4051,7 +4055,7 @@ impl AgentPanel { entry = entry .when( is_agent_selected(AgentType::Custom { - name: item.id.0.clone(), + id: item.id.clone(), }), |this| { this.action(Box::new( @@ -4073,7 +4077,7 @@ impl AgentPanel { panel.update(cx, |panel, cx| { panel.new_agent_thread( AgentType::Custom { - name: agent_id.0.clone(), + id: agent_id.clone(), }, window, cx, @@ -4098,20 +4102,20 @@ impl AgentPanel { let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx)); - let previous_built_in_ids: &[ExternalAgentServerName] = - &[CLAUDE_AGENT_NAME.into(), CODEX_NAME.into(), GEMINI_NAME.into()]; + let previous_built_in_ids: &[AgentId] = + &[CLAUDE_AGENT_ID.into(), CODEX_ID.into(), GEMINI_ID.into()]; let promoted_items = previous_built_in_ids .iter() .filter(|id| { !agent_server_store.external_agents.contains_key(*id) }) - .filter_map(|name| { + .filter_map(|id| { let display_name = registry_store_ref .as_ref() - .and_then(|store| store.agent(name.0.as_ref())) + .and_then(|store| store.agent(&id)) .map(|a| a.name().clone())?; - Some((name.clone(), display_name)) + Some((id.clone(), display_name)) }) .sorted_unstable_by_key(|(_, display_name)| display_name.to_lowercase()) .collect::>(); @@ -4122,7 +4126,7 @@ impl AgentPanel { let icon_path = registry_store_ref .as_ref() - .and_then(|store| store.agent(agent_id.0.as_str())) + .and_then(|store| store.agent(agent_id)) .and_then(|a| a.icon_path().cloned()); if let Some(icon_path) = icon_path { @@ -4169,7 +4173,7 @@ impl AgentPanel { panel.update(cx, |panel, cx| { panel.new_agent_thread( AgentType::Custom { - name: agent_id.0.clone(), + id: agent_id.clone(), }, window, cx, @@ -5217,7 +5221,7 @@ impl AgentPanel { let project = self.project.clone(); let ext_agent = Agent::Custom { - name: server.name(), + id: server.agent_id(), }; self.create_agent_thread( @@ -5379,7 +5383,7 @@ mod tests { panel_b.update(cx, |panel, _cx| { panel.width = Some(px(400.0)); panel.selected_agent_type = AgentType::Custom { - name: "claude-acp".into(), + id: "claude-acp".into(), }; }); @@ -5430,7 +5434,7 @@ mod tests { assert_eq!( panel.selected_agent_type, AgentType::Custom { - name: "claude-acp".into() + id: "claude-acp".into() }, "workspace B agent type should be restored" ); @@ -6229,25 +6233,25 @@ mod tests { assert_eq!( serde_json::from_str::(r#""ClaudeAgent""#).unwrap(), AgentType::Custom { - name: CLAUDE_AGENT_NAME.into(), + id: CLAUDE_AGENT_ID.into(), }, ); assert_eq!( serde_json::from_str::(r#""ClaudeCode""#).unwrap(), AgentType::Custom { - name: CLAUDE_AGENT_NAME.into(), + id: CLAUDE_AGENT_ID.into(), }, ); assert_eq!( serde_json::from_str::(r#""Codex""#).unwrap(), AgentType::Custom { - name: CODEX_NAME.into(), + id: CODEX_ID.into(), }, ); assert_eq!( serde_json::from_str::(r#""Gemini""#).unwrap(), AgentType::Custom { - name: GEMINI_NAME.into(), + id: GEMINI_ID.into(), }, ); } @@ -6265,7 +6269,7 @@ mod tests { assert_eq!( serde_json::from_str::(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(), AgentType::Custom { - name: "my-agent".into(), + id: "my-agent".into(), }, ); } @@ -6285,14 +6289,14 @@ mod tests { assert_eq!( panel.selected_agent, Some(AgentType::Custom { - name: CLAUDE_AGENT_NAME.into(), + id: CLAUDE_AGENT_ID.into(), }), ); let thread = panel.last_active_thread.unwrap(); assert_eq!( thread.agent_type, AgentType::Custom { - name: CODEX_NAME.into(), + id: CODEX_ID.into(), }, ); } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index db0cf873418ea38f8d5771c13b281528218fb94e..e0ab9a707b7e7ab5fed6a0a27d4c253c08445dfa 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -34,6 +34,7 @@ mod text_thread_editor; mod text_thread_history; mod thread_history; mod thread_history_view; +mod thread_metadata_store; mod threads_archive_view; mod ui; @@ -55,7 +56,7 @@ use language::{ use language_model::{ ConfiguredModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, }; -use project::DisableAiSettings; +use project::{AgentId, DisableAiSettings}; use prompt_store::PromptBuilder; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -221,7 +222,10 @@ pub struct NewNativeAgentThreadFromSummary { #[serde(rename_all = "snake_case")] pub enum Agent { NativeAgent, - Custom { name: SharedString }, + Custom { + #[serde(rename = "name")] + id: AgentId, + }, } // Custom impl handles legacy variant names from before the built-in agents were moved to @@ -233,7 +237,7 @@ impl<'de> serde::Deserialize<'de> for Agent { where D: serde::Deserializer<'de>, { - use project::agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME}; + use project::agent_server_store::{CLAUDE_AGENT_ID, CODEX_ID, GEMINI_ID}; let value = serde_json::Value::deserialize(deserializer)?; @@ -241,13 +245,13 @@ impl<'de> serde::Deserialize<'de> for Agent { return match s { "native_agent" => Ok(Self::NativeAgent), "claude_code" | "claude_agent" => Ok(Self::Custom { - name: CLAUDE_AGENT_NAME.into(), + id: CLAUDE_AGENT_ID.into(), }), "codex" => Ok(Self::Custom { - name: CODEX_NAME.into(), + id: CODEX_ID.into(), }), "gemini" => Ok(Self::Custom { - name: GEMINI_NAME.into(), + id: GEMINI_ID.into(), }), other => Err(serde::de::Error::unknown_variant( other, @@ -271,7 +275,9 @@ impl<'de> serde::Deserialize<'de> for Agent { } let fields: CustomFields = serde_json::from_value(inner.clone()).map_err(serde::de::Error::custom)?; - return Ok(Self::Custom { name: fields.name }); + return Ok(Self::Custom { + id: AgentId::new(fields.name), + }); } } @@ -289,7 +295,9 @@ impl Agent { ) -> Rc { match self { Self::NativeAgent => Rc::new(agent::NativeAgentServer::new(fs, thread_store)), - Self::Custom { name } => Rc::new(agent_servers::CustomAgentServer::new(name.clone())), + Self::Custom { id: name } => { + Rc::new(agent_servers::CustomAgentServer::new(name.clone())) + } } } } @@ -378,6 +386,7 @@ pub fn init( agent_panel::init(cx); context_server_configuration::init(language_registry.clone(), fs.clone(), cx); TextThreadEditor::init(cx); + thread_metadata_store::init(cx); register_slash_commands(cx); inline_assistant::init(fs.clone(), prompt_builder.clone(), cx); @@ -751,24 +760,24 @@ mod tests { #[test] fn test_deserialize_legacy_external_agent_variants() { - use project::agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME}; + use project::agent_server_store::{CLAUDE_AGENT_ID, CODEX_ID, GEMINI_ID}; assert_eq!( serde_json::from_str::(r#""claude_code""#).unwrap(), Agent::Custom { - name: CLAUDE_AGENT_NAME.into(), + id: CLAUDE_AGENT_ID.into(), }, ); assert_eq!( serde_json::from_str::(r#""codex""#).unwrap(), Agent::Custom { - name: CODEX_NAME.into(), + id: CODEX_ID.into(), }, ); assert_eq!( serde_json::from_str::(r#""gemini""#).unwrap(), Agent::Custom { - name: GEMINI_NAME.into(), + id: GEMINI_ID.into(), }, ); } @@ -782,7 +791,7 @@ mod tests { assert_eq!( serde_json::from_str::(r#"{"custom":{"name":"my-agent"}}"#).unwrap(), Agent::Custom { - name: "my-agent".into(), + id: "my-agent".into(), }, ); } diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index 4d352c6a8494f97358ee012740e539c750308886..42d4fe4e4ef6b69e53d951e0007e564d5cc614a4 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -36,12 +36,12 @@ use gpui::{ use language::Buffer; use language_model::LanguageModelRegistry; use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle}; -use project::{AgentServerStore, ExternalAgentServerName, Project, ProjectEntryId}; +use project::{AgentId, AgentServerStore, Project, ProjectEntryId}; use prompt_store::{PromptId, PromptStore}; use rope::Point; use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore}; use std::cell::RefCell; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; use std::time::Instant; use std::{collections::BTreeMap, rc::Rc, time::Duration}; @@ -56,6 +56,7 @@ use ui::{ }; use util::{ResultExt, size::format_file_size, time::duration_alt_display}; use util::{debug_panic, defer}; +use workspace::PathList; use workspace::{ CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId, }; @@ -74,6 +75,7 @@ use crate::agent_diff::AgentDiff; use crate::entry_view_state::{EntryViewEvent, ViewEvent}; use crate::message_editor::{MessageEditor, MessageEditorEvent}; use crate::profile_selector::{ProfileProvider, ProfileSelector}; +use crate::thread_metadata_store::ThreadMetadataStore; use crate::ui::{AgentNotification, AgentNotificationEvent}; use crate::{ Agent, AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce, @@ -482,7 +484,7 @@ impl ConnectionView { connection_store: Entity, connection_key: Agent, resume_session_id: Option, - cwd: Option, + work_dirs: Option, title: Option, initial_content: Option, workspace: WeakEntity, @@ -531,7 +533,7 @@ impl ConnectionView { connection_store, connection_key, resume_session_id, - cwd, + work_dirs, title, project, initial_content, @@ -563,7 +565,7 @@ impl ConnectionView { let thread = thread_view.read(cx).thread.read(cx); ( Some(thread.session_id().clone()), - thread.cwd().cloned(), + thread.work_dirs().cloned(), Some(thread.title()), ) }) @@ -602,7 +604,7 @@ impl ConnectionView { connection_store: Entity, connection_key: Agent, resume_session_id: Option, - cwd: Option, + work_dirs: Option, title: Option, project: Entity, initial_content: Option, @@ -638,24 +640,13 @@ impl ConnectionView { } }) .collect(); - let session_cwd = cwd - .filter(|cwd| { - // Validate with the normalized path (rejects `..` traversals), - // but return the original cwd to preserve its path separators. - // On Windows, `normalize_lexically` rebuilds the path with - // backslashes via `PathBuf::push`, which would corrupt - // forward-slash Linux paths used by WSL agents. - util::paths::normalize_lexically(cwd) - .ok() - .is_some_and(|normalized| { - worktree_roots - .iter() - .any(|root| normalized.starts_with(root.as_ref())) - }) - }) - .map(|path| path.into()) - .or_else(|| worktree_roots.first().cloned()) - .unwrap_or_else(|| paths::home_dir().as_path().into()); + let session_work_dirs = work_dirs.unwrap_or_else(|| { + if worktree_roots.is_empty() { + PathList::new(&[paths::home_dir().as_path()]) + } else { + PathList::new(&worktree_roots) + } + }); let connection_entry = connection_store.update(cx, |store, cx| { store.request_connection(connection_key, agent.clone(), cx) @@ -701,7 +692,7 @@ impl ConnectionView { connection.clone().load_session( session_id, project.clone(), - &session_cwd, + session_work_dirs, title, cx, ) @@ -710,7 +701,7 @@ impl ConnectionView { connection.clone().resume_session( session_id, project.clone(), - &session_cwd, + session_work_dirs, title, cx, ) @@ -725,7 +716,7 @@ impl ConnectionView { cx.update(|_, cx| { connection .clone() - .new_session(project.clone(), session_cwd.as_ref(), cx) + .new_session(project.clone(), session_work_dirs, cx) }) .log_err() }; @@ -741,7 +732,7 @@ impl ConnectionView { Self::handle_auth_required( this, err, - agent.name(), + agent.agent_id(), connection, window, cx, @@ -829,7 +820,7 @@ impl ConnectionView { window: &mut Window, cx: &mut Context, ) -> Entity { - let agent_name = self.agent.name(); + let agent_id = self.agent.agent_id(); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![])); @@ -846,7 +837,7 @@ impl ConnectionView { self.prompt_store.clone(), prompt_capabilities.clone(), available_commands.clone(), - self.agent.name(), + self.agent.agent_id(), ) }); @@ -969,19 +960,19 @@ impl ConnectionView { let agent_display_name = self .agent_server_store .read(cx) - .agent_display_name(&ExternalAgentServerName(agent_name.clone())) - .unwrap_or_else(|| agent_name.clone()); + .agent_display_name(&agent_id.clone()) + .unwrap_or_else(|| agent_id.0.clone()); let agent_icon = self.agent.logo(); let agent_icon_from_external_svg = self .agent_server_store .read(cx) - .agent_icon(&ExternalAgentServerName(self.agent.name())) + .agent_icon(&self.agent.agent_id()) .or_else(|| { project::AgentRegistryStore::try_global(cx).and_then(|store| { store .read(cx) - .agent(self.agent.name().as_ref()) + .agent(&self.agent.agent_id()) .and_then(|a| a.icon_path().cloned()) }) }); @@ -995,7 +986,7 @@ impl ConnectionView { weak, agent_icon, agent_icon_from_external_svg, - agent_name, + agent_id, agent_display_name, self.workspace.clone(), entry_view_state, @@ -1022,7 +1013,7 @@ impl ConnectionView { fn handle_auth_required( this: WeakEntity, err: AuthRequired, - agent_name: SharedString, + agent_id: AgentId, connection: Rc, window: &mut Window, cx: &mut App, @@ -1051,7 +1042,7 @@ impl ConnectionView { let view = registry.read(cx).provider(&provider_id).map(|provider| { provider.configuration_view( - language_model::ConfigurationViewTargetAgent::Other(agent_name), + language_model::ConfigurationViewTargetAgent::Other(agent_id.0), window, cx, ) @@ -1166,12 +1157,14 @@ impl ConnectionView { ServerState::Connected(_) => "New Thread".into(), ServerState::Loading(_) => "Loading…".into(), ServerState::LoadError { error, .. } => match error { - LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(), + LoadError::Unsupported { .. } => { + format!("Upgrade {}", self.agent.agent_id()).into() + } LoadError::FailedToInstall(_) => { - format!("Failed to Install {}", self.agent.name()).into() + format!("Failed to Install {}", self.agent.agent_id()).into() } - LoadError::Exited { .. } => format!("{} Exited", self.agent.name()).into(), - LoadError::Other(_) => format!("Error Loading {}", self.agent.name()).into(), + LoadError::Exited { .. } => format!("{} Exited", self.agent.agent_id()).into(), + LoadError::Other(_) => format!("Error Loading {}", self.agent.agent_id()).into(), }, } } @@ -1451,8 +1444,8 @@ impl ConnectionView { let agent_display_name = self .agent_server_store .read(cx) - .agent_display_name(&ExternalAgentServerName(self.agent.name())) - .unwrap_or_else(|| self.agent.name()); + .agent_display_name(&self.agent.agent_id()) + .unwrap_or_else(|| self.agent.agent_id().0.to_string().into()); if let Some(active) = self.active_thread() { let new_placeholder = @@ -1673,24 +1666,21 @@ impl ConnectionView { { return; } - let root_dir = self - .project + let Some(parent_thread) = connected.threads.get(&parent_id) else { + return; + }; + let work_dirs = parent_thread .read(cx) - .worktrees(cx) - .filter_map(|worktree| { - if worktree.read(cx).is_single_file() { - Some(worktree.read(cx).abs_path().parent()?.into()) - } else { - Some(worktree.read(cx).abs_path()) - } - }) - .next(); - let cwd = root_dir.unwrap_or_else(|| paths::home_dir().as_path().into()); + .thread + .read(cx) + .work_dirs() + .cloned() + .unwrap_or_else(|| PathList::new(&[paths::home_dir().as_path()])); let subagent_thread_task = connected.connection.clone().load_session( subagent_id.clone(), self.project.clone(), - &cwd, + work_dirs, None, cx, ); @@ -1876,8 +1866,8 @@ impl ConnectionView { let agent_display_name = self .agent_server_store .read(cx) - .agent_display_name(&ExternalAgentServerName(self.agent.name())) - .unwrap_or_else(|| self.agent.name()); + .agent_display_name(&self.agent.agent_id()) + .unwrap_or_else(|| self.agent.agent_id().0); let show_fallback_description = auth_methods.len() > 1 && configuration_view.is_none() @@ -2038,7 +2028,7 @@ impl ConnectionView { LoadError::Other(_) => "other", }; - let agent_name = self.agent.name(); + let agent_name = self.agent.agent_id(); telemetry::event!( "Agent Panel Error Shown", @@ -2097,7 +2087,7 @@ impl ConnectionView { cx: &mut Context, ) -> AnyElement { let (heading_label, description_label) = ( - format!("Upgrade {} to work with Zed", self.agent.name()), + format!("Upgrade {} to work with Zed", self.agent.agent_id()), if version.is_empty() { format!( "Currently using {}, which does not report a valid --version", @@ -2217,7 +2207,7 @@ impl ConnectionView { let needed_count = self.queued_messages_len(cx); let queued_messages = self.queued_message_contents(cx); - let agent_name = self.agent.name(); + let agent_name = self.agent.agent_id(); let workspace = self.workspace.clone(); let project = self.project.downgrade(); let Some(connected) = self.as_connected() else { @@ -2396,7 +2386,7 @@ impl ConnectionView { } // TODO: Change this once we have title summarization for external agents. - let title = self.agent.name(); + let title = self.agent.agent_id().0; match settings.notify_when_agent_waiting { NotifyWhenAgentWaiting::PrimaryScreen => { @@ -2585,7 +2575,7 @@ impl ConnectionView { .unwrap_or_else(|| SharedString::from("The model")) } else { // ACP agent - use the agent name (e.g., "Claude Agent", "Gemini CLI") - self.agent.name() + self.agent.agent_id().0 } } @@ -2596,7 +2586,7 @@ impl ConnectionView { } pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context) { - let agent_name = self.agent.name(); + let agent_id = self.agent.agent_id(); if let Some(active) = self.active_thread() { active.update(cx, |active, cx| active.clear_thread_error(cx)); } @@ -2606,14 +2596,7 @@ impl ConnectionView { return; }; window.defer(cx, |window, cx| { - Self::handle_auth_required( - this, - AuthRequired::new(), - agent_name, - connection, - window, - cx, - ); + Self::handle_auth_required(this, AuthRequired::new(), agent_id, connection, window, cx); }) } @@ -2630,6 +2613,12 @@ impl ConnectionView { .history .update(cx, |history, cx| history.delete_session(&session_id, cx)); task.detach_and_log_err(cx); + + if let Some(store) = ThreadMetadataStore::try_global(cx) { + store + .update(cx, |store, cx| store.delete(session_id.clone(), cx)) + .detach_and_log_err(cx); + } } } @@ -2642,7 +2631,7 @@ fn loading_contents_spinner(size: IconSize) -> AnyElement { } fn placeholder_text(agent_name: &str, has_commands: bool) -> String { - if agent_name == "Zed Agent" { + if agent_name == agent::ZED_AGENT_ID.as_ref() { format!("Message the {} — @ to include context", agent_name) } else if has_commands { format!( @@ -2923,9 +2912,7 @@ pub(crate) mod tests { ConnectionView::new( Rc::new(StubAgentServer::default_response()), connection_store, - Agent::Custom { - name: "Test".into(), - }, + Agent::Custom { id: "Test".into() }, None, None, None, @@ -3035,9 +3022,7 @@ pub(crate) mod tests { ConnectionView::new( Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)), connection_store, - Agent::Custom { - name: "Test".into(), - }, + Agent::Custom { id: "Test".into() }, Some(SessionId::new("resume-session")), None, None, @@ -3081,7 +3066,7 @@ pub(crate) mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let connection = CwdCapturingConnection::new(); - let captured_cwd = connection.captured_cwd.clone(); + let captured_cwd = connection.captured_work_dirs.clone(); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let connection_store = @@ -3092,11 +3077,9 @@ pub(crate) mod tests { ConnectionView::new( Rc::new(StubAgentServer::new(connection)), connection_store, - Agent::Custom { - name: "Test".into(), - }, + Agent::Custom { id: "Test".into() }, Some(SessionId::new("session-1")), - Some(PathBuf::from("/project/subdir")), + Some(PathList::new(&[PathBuf::from("/project/subdir")])), None, None, workspace.downgrade(), @@ -3112,122 +3095,12 @@ pub(crate) mod tests { cx.run_until_parked(); assert_eq!( - captured_cwd.lock().as_deref(), - Some(Path::new("/project/subdir")), + captured_cwd.lock().as_ref().unwrap(), + &PathList::new(&[Path::new("/project/subdir")]), "Should use session cwd when it's inside the project" ); } - #[gpui::test] - async fn test_resume_thread_uses_fallback_cwd_when_outside_project(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - "file.txt": "hello" - }), - ) - .await; - let project = Project::test(fs, [Path::new("/project")], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let connection = CwdCapturingConnection::new(); - let captured_cwd = connection.captured_cwd.clone(); - - let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let connection_store = - cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); - - let _thread_view = cx.update(|window, cx| { - cx.new(|cx| { - ConnectionView::new( - Rc::new(StubAgentServer::new(connection)), - connection_store, - Agent::Custom { - name: "Test".into(), - }, - Some(SessionId::new("session-1")), - Some(PathBuf::from("/some/other/path")), - None, - None, - workspace.downgrade(), - project, - Some(thread_store), - None, - window, - cx, - ) - }) - }); - - cx.run_until_parked(); - - assert_eq!( - captured_cwd.lock().as_deref(), - Some(Path::new("/project")), - "Should use fallback project cwd when session cwd is outside the project" - ); - } - - #[gpui::test] - async fn test_resume_thread_rejects_unnormalized_cwd_outside_project(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - "file.txt": "hello" - }), - ) - .await; - let project = Project::test(fs, [Path::new("/project")], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let connection = CwdCapturingConnection::new(); - let captured_cwd = connection.captured_cwd.clone(); - - let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let connection_store = - cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); - - let _thread_view = cx.update(|window, cx| { - cx.new(|cx| { - ConnectionView::new( - Rc::new(StubAgentServer::new(connection)), - connection_store, - Agent::Custom { - name: "Test".into(), - }, - Some(SessionId::new("session-1")), - Some(PathBuf::from("/project/../outside")), - None, - None, - workspace.downgrade(), - project, - Some(thread_store), - None, - window, - cx, - ) - }) - }); - - cx.run_until_parked(); - - assert_eq!( - captured_cwd.lock().as_deref(), - Some(Path::new("/project")), - "Should reject unnormalized cwd that resolves outside the project and use fallback cwd" - ); - } - #[gpui::test] async fn test_refusal_handling(cx: &mut TestAppContext) { init_test(cx); @@ -3519,9 +3392,7 @@ pub(crate) mod tests { ConnectionView::new( Rc::new(agent), connection_store, - Agent::Custom { - name: "Test".into(), - }, + Agent::Custom { id: "Test".into() }, None, None, None, @@ -3734,9 +3605,7 @@ pub(crate) mod tests { let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); - let agent_key = Agent::Custom { - name: "Test".into(), - }; + let agent_key = Agent::Custom { id: "Test".into() }; let thread_view = cx.update(|window, cx| { cx.new(|cx| { @@ -3849,7 +3718,7 @@ pub(crate) mod tests { ui::IconName::Ai } - fn name(&self) -> SharedString { + fn agent_id(&self) -> AgentId { "Test".into() } @@ -3873,8 +3742,8 @@ pub(crate) mod tests { ui::IconName::AiOpenAi } - fn name(&self) -> SharedString { - "Codex CLI".into() + fn agent_id(&self) -> AgentId { + AgentId::new("Codex CLI") } fn connect( @@ -3960,6 +3829,10 @@ pub(crate) mod tests { } impl AgentConnection for SessionHistoryConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("history-connection") + } + fn telemetry_id(&self) -> SharedString { "history-connection".into() } @@ -3967,7 +3840,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - _cwd: &Path, + _work_dirs: PathList, cx: &mut App, ) -> Task>> { let thread = build_test_thread( @@ -4020,6 +3893,10 @@ pub(crate) mod tests { struct ResumeOnlyAgentConnection; impl AgentConnection for ResumeOnlyAgentConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("resume-only") + } + fn telemetry_id(&self) -> SharedString { "resume-only".into() } @@ -4027,7 +3904,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - _cwd: &Path, + _work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { let thread = build_test_thread( @@ -4048,7 +3925,7 @@ pub(crate) mod tests { self: Rc, session_id: acp::SessionId, project: Entity, - _cwd: &Path, + _work_dirs: PathList, _title: Option, cx: &mut App, ) -> Task>> { @@ -4109,6 +3986,10 @@ pub(crate) mod tests { } impl AgentConnection for AuthGatedAgentConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("auth-gated") + } + fn telemetry_id(&self) -> SharedString { "auth-gated".into() } @@ -4116,7 +3997,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { if !*self.authenticated.lock() { @@ -4131,7 +4012,7 @@ pub(crate) mod tests { AcpThread::new( None, "AuthGatedAgent", - Some(cwd.to_path_buf()), + Some(work_dirs), self, project, action_log, @@ -4186,6 +4067,10 @@ pub(crate) mod tests { struct SaboteurAgentConnection; impl AgentConnection for SaboteurAgentConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("saboteur") + } + fn telemetry_id(&self) -> SharedString { "saboteur".into() } @@ -4193,7 +4078,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { Task::ready(Ok(cx.new(|cx| { @@ -4201,7 +4086,7 @@ pub(crate) mod tests { AcpThread::new( None, "SaboteurAgentConnection", - Some(cwd.to_path_buf()), + Some(work_dirs), self, project, action_log, @@ -4252,6 +4137,10 @@ pub(crate) mod tests { struct RefusalAgentConnection; impl AgentConnection for RefusalAgentConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("refusal") + } + fn telemetry_id(&self) -> SharedString { "refusal".into() } @@ -4259,7 +4148,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { Task::ready(Ok(cx.new(|cx| { @@ -4267,7 +4156,7 @@ pub(crate) mod tests { AcpThread::new( None, "RefusalAgentConnection", - Some(cwd.to_path_buf()), + Some(work_dirs), self, project, action_log, @@ -4315,18 +4204,22 @@ pub(crate) mod tests { #[derive(Clone)] struct CwdCapturingConnection { - captured_cwd: Arc>>, + captured_work_dirs: Arc>>, } impl CwdCapturingConnection { fn new() -> Self { Self { - captured_cwd: Arc::new(Mutex::new(None)), + captured_work_dirs: Arc::new(Mutex::new(None)), } } } impl AgentConnection for CwdCapturingConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("cwd-capturing") + } + fn telemetry_id(&self) -> SharedString { "cwd-capturing".into() } @@ -4334,16 +4227,16 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { - *self.captured_cwd.lock() = Some(cwd.to_path_buf()); + *self.captured_work_dirs.lock() = Some(work_dirs.clone()); let action_log = cx.new(|_| ActionLog::new(project.clone())); let thread = cx.new(|cx| { AcpThread::new( None, "CwdCapturingConnection", - Some(cwd.to_path_buf()), + Some(work_dirs), self.clone(), project, action_log, @@ -4368,17 +4261,17 @@ pub(crate) mod tests { self: Rc, session_id: acp::SessionId, project: Entity, - cwd: &Path, + work_dirs: PathList, _title: Option, cx: &mut App, ) -> Task>> { - *self.captured_cwd.lock() = Some(cwd.to_path_buf()); + *self.captured_work_dirs.lock() = Some(work_dirs.clone()); let action_log = cx.new(|_| ActionLog::new(project.clone())); let thread = cx.new(|cx| { AcpThread::new( None, "CwdCapturingConnection", - Some(cwd.to_path_buf()), + Some(work_dirs), self.clone(), project, action_log, @@ -4427,6 +4320,7 @@ pub(crate) mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); + ThreadMetadataStore::init_global(cx); theme::init(theme::LoadThemes::JustBase, cx); editor::init(cx); agent_panel::init(cx); @@ -4484,9 +4378,7 @@ pub(crate) mod tests { ConnectionView::new( Rc::new(StubAgentServer::new(connection.as_ref().clone())), connection_store, - Agent::Custom { - name: "Test".into(), - }, + Agent::Custom { id: "Test".into() }, None, None, None, @@ -6562,9 +6454,7 @@ pub(crate) mod tests { ConnectionView::new( Rc::new(StubAgentServer::default_response()), connection_store, - Agent::Custom { - name: "Test".into(), - }, + Agent::Custom { id: "Test".into() }, None, None, None, @@ -6693,6 +6583,10 @@ pub(crate) mod tests { } impl AgentConnection for CloseCapableConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("close-capable") + } + fn telemetry_id(&self) -> SharedString { "close-capable".into() } @@ -6700,7 +6594,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { let action_log = cx.new(|_| ActionLog::new(project.clone())); @@ -6708,7 +6602,7 @@ pub(crate) mod tests { AcpThread::new( None, "CloseCapableConnection", - Some(cwd.to_path_buf()), + Some(work_dirs), self, project, action_log, diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 29ba06f470d78d60772b63ce54802647ef303444..ed2a062c0aed13c60c8ea15193bb598764a31806 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -170,7 +170,7 @@ pub struct ThreadView { pub server_view: WeakEntity, pub agent_icon: IconName, pub agent_icon_from_external_svg: Option, - pub agent_name: SharedString, + pub agent_id: AgentId, pub focus_handle: FocusHandle, pub workspace: WeakEntity, pub entry_view_state: Entity, @@ -259,7 +259,7 @@ impl ThreadView { server_view: WeakEntity, agent_icon: IconName, agent_icon_from_external_svg: Option, - agent_name: SharedString, + agent_id: AgentId, agent_display_name: SharedString, workspace: WeakEntity, entry_view_state: Entity, @@ -300,7 +300,7 @@ impl ThreadView { prompt_store, prompt_capabilities.clone(), available_commands.clone(), - agent_name.clone(), + agent_id.clone(), &placeholder, editor::EditorMode::AutoHeight { min_lines: AgentSettings::get_global(cx).message_editor_min_lines, @@ -342,7 +342,7 @@ impl ThreadView { let show_codex_windows_warning = cfg!(windows) && project.upgrade().is_some_and(|p| p.read(cx).is_local()) - && agent_name == "Codex"; + && agent_id.as_ref() == "Codex"; let title_editor = { let can_edit = thread.update(cx, |thread, cx| thread.can_set_title(cx)); @@ -403,7 +403,7 @@ impl ThreadView { server_view, agent_icon, agent_icon_from_external_svg, - agent_name, + agent_id, workspace, entry_view_state, title_editor, @@ -879,13 +879,13 @@ impl ThreadView { let connection = self.thread.read(cx).connection().clone(); window.defer(cx, { - let agent_name = self.agent_name.clone(); + let agent_id = self.agent_id.clone(); let server_view = self.server_view.clone(); move |window, cx| { ConnectionView::handle_auth_required( server_view.clone(), AuthRequired::new(), - agent_name, + agent_id, connection, window, cx, @@ -3722,16 +3722,16 @@ impl ThreadView { let following = self.is_following(cx); let tooltip_label = if following { - if self.agent_name == "Zed Agent" { - format!("Stop Following the {}", self.agent_name) + if self.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() { + format!("Stop Following the {}", self.agent_id) } else { - format!("Stop Following {}", self.agent_name) + format!("Stop Following {}", self.agent_id) } } else { - if self.agent_name == "Zed Agent" { - format!("Follow the {}", self.agent_name) + if self.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() { + format!("Follow the {}", self.agent_id) } else { - format!("Follow {}", self.agent_name) + format!("Follow {}", self.agent_id) } }; @@ -3823,7 +3823,7 @@ impl ThreadView { let agent_name = if is_subagent { "subagents".into() } else { - self.agent_name.clone() + self.agent_id.clone() }; v_flex() @@ -7308,7 +7308,7 @@ impl ThreadView { .on_click(cx.listener({ move |this, _, window, cx| { let server_view = this.server_view.clone(); - let agent_name = this.agent_name.clone(); + let agent_name = this.agent_id.clone(); this.clear_thread_error(cx); if let Some(message) = this.in_flight_prompt.take() { @@ -7343,7 +7343,7 @@ impl ThreadView { .unwrap_or_else(|| SharedString::from("The model")) } else { // ACP agent - use the agent name (e.g., "Claude Agent", "Gemini CLI") - self.agent_name.clone() + self.agent_id.0.clone() } } diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index 17769335a1cc7e514bad15862d20d4048a089b7b..92075616547d7917119b42cf762557ce163d0a2a 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -8,10 +8,10 @@ use collections::HashMap; use editor::{Editor, EditorEvent, EditorMode, MinimapVisibility, SizingBehavior}; use gpui::{ AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, FocusHandle, Focusable, - ScrollHandle, SharedString, TextStyleRefinement, WeakEntity, Window, + ScrollHandle, TextStyleRefinement, WeakEntity, Window, }; use language::language_settings::SoftWrap; -use project::Project; +use project::{AgentId, Project}; use prompt_store::PromptStore; use rope::Point; use settings::Settings as _; @@ -31,7 +31,7 @@ pub struct EntryViewState { entries: Vec, prompt_capabilities: Rc>, available_commands: Rc>>, - agent_name: SharedString, + agent_id: AgentId, } impl EntryViewState { @@ -43,7 +43,7 @@ impl EntryViewState { prompt_store: Option>, prompt_capabilities: Rc>, available_commands: Rc>>, - agent_name: SharedString, + agent_id: AgentId, ) -> Self { Self { workspace, @@ -54,7 +54,7 @@ impl EntryViewState { entries: Vec::new(), prompt_capabilities, available_commands, - agent_name, + agent_id, } } @@ -96,7 +96,7 @@ impl EntryViewState { self.prompt_store.clone(), self.prompt_capabilities.clone(), self.available_commands.clone(), - self.agent_name.clone(), + self.agent_id.clone(), "Edit message - @ to include context", editor::EditorMode::AutoHeight { min_lines: 1, @@ -468,7 +468,7 @@ mod tests { use serde_json::json; use settings::SettingsStore; use util::path; - use workspace::MultiWorkspace; + use workspace::{MultiWorkspace, PathList}; #[gpui::test] async fn test_diff_sync(cx: &mut TestAppContext) { @@ -495,9 +495,11 @@ mod tests { let connection = Rc::new(StubAgentConnection::new()); let thread = cx .update(|_, cx| { - connection - .clone() - .new_session(project.clone(), Path::new(path!("/project")), cx) + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new(path!("/project"))]), + cx, + ) }) .await .unwrap(); diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 4170417df0c5fdfcdb86f2e4c0478c0ef59cefa9..6c62bd2f81ded87ae3b2aec4ac23473bc3324b8c 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -27,6 +27,7 @@ use gpui::{ KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity, }; use language::{Buffer, Language, language_settings::InlayHintKind}; +use project::AgentId; use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree}; use prompt_store::PromptStore; use rope::Point; @@ -45,7 +46,7 @@ pub struct MessageEditor { workspace: WeakEntity, prompt_capabilities: Rc>, available_commands: Rc>>, - agent_name: SharedString, + agent_id: AgentId, thread_store: Option>, _subscriptions: Vec, _parse_slash_command_task: Task<()>, @@ -113,7 +114,7 @@ impl MessageEditor { prompt_store: Option>, prompt_capabilities: Rc>, available_commands: Rc>>, - agent_name: SharedString, + agent_id: AgentId, placeholder: &str, mode: EditorMode, window: &mut Window, @@ -236,7 +237,7 @@ impl MessageEditor { workspace, prompt_capabilities, available_commands, - agent_name, + agent_id, thread_store, _subscriptions: subscriptions, _parse_slash_command_task: Task::ready(()), @@ -379,7 +380,7 @@ impl MessageEditor { fn validate_slash_commands( text: &str, available_commands: &[acp::AvailableCommand], - agent_name: &str, + agent_id: &AgentId, ) -> Result<()> { if let Some(parsed_command) = SlashCommandCompletion::try_parse(text, 0) { if let Some(command_name) = parsed_command.command { @@ -392,7 +393,7 @@ impl MessageEditor { return Err(anyhow!( "The /{} command is not supported by {}.\n\nAvailable commands: {}", command_name, - agent_name, + agent_id, if available_commands.is_empty() { "none".to_string() } else { @@ -416,11 +417,11 @@ impl MessageEditor { ) -> Task, Vec>)>> { let text = self.editor.read(cx).text(cx); let available_commands = self.available_commands.borrow().clone(); - let agent_name = self.agent_name.clone(); + let agent_id = self.agent_id.clone(); let build_task = self.build_content_blocks(full_mention_content, cx); cx.spawn(async move |_, _cx| { - Self::validate_slash_commands(&text, &available_commands, &agent_name)?; + Self::validate_slash_commands(&text, &available_commands, &agent_id)?; build_task.await }) } diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index 333146bd7ac43f7a9c3851de1f4a7e6176609368..b2634a807fe0a0588536e822f2ec06c6d2099c09 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -1,9 +1,10 @@ +use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}; use crate::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent}; use crate::{Agent, AgentPanel, AgentPanelEvent, NewThread, RemoveSelectedThread}; use acp_thread::ThreadStatus; use action_log::DiffStats; use agent::ThreadStore; -use agent_client_protocol as acp; +use agent_client_protocol::{self as acp}; use agent_settings::AgentSettings; use chrono::Utc; use db::kvp::KEY_VALUE_STORE; @@ -14,7 +15,7 @@ use gpui::{ Render, SharedString, WeakEntity, Window, actions, list, prelude::*, px, }; use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; -use project::Event as ProjectEvent; +use project::{AgentId, Event as ProjectEvent}; use settings::Settings; use std::collections::{HashMap, HashSet}; use std::mem; @@ -91,7 +92,7 @@ impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo { fn from(info: &ActiveThreadInfo) -> Self { Self { session_id: info.session_id.clone(), - cwd: None, + work_dirs: None, title: Some(info.title.clone()), updated_at: Some(Utc::now()), created_at: Some(Utc::now()), @@ -251,6 +252,7 @@ pub struct Sidebar { view: SidebarView, archive_view: Option>, _subscriptions: Vec, + _update_entries_task: Option>, } impl Sidebar { @@ -274,14 +276,14 @@ impl Sidebar { window, |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event { MultiWorkspaceEvent::ActiveWorkspaceChanged => { - this.update_entries(cx); + this.update_entries(false, cx); } MultiWorkspaceEvent::WorkspaceAdded(workspace) => { this.subscribe_to_workspace(workspace, window, cx); - this.update_entries(cx); + this.update_entries(false, cx); } MultiWorkspaceEvent::WorkspaceRemoved(_) => { - this.update_entries(cx); + this.update_entries(false, cx); } }, ) @@ -293,33 +295,18 @@ impl Sidebar { if !query.is_empty() { this.selection.take(); } - this.update_entries(cx); - if !query.is_empty() { - this.selection = this - .contents - .entries - .iter() - .position(|entry| matches!(entry, ListEntry::Thread(_))) - .or_else(|| { - if this.contents.entries.is_empty() { - None - } else { - Some(0) - } - }); - } + this.update_entries(!query.is_empty(), cx); } }) .detach(); - let thread_store = ThreadStore::global(cx); - cx.observe_in(&thread_store, window, |this, _, _window, cx| { - this.update_entries(cx); + cx.observe(&ThreadMetadataStore::global(cx), |this, _store, cx| { + this.update_entries(false, cx); }) .detach(); cx.observe_flag::(window, |_is_enabled, this, _window, cx| { - this.update_entries(cx); + this.update_entries(false, cx); }) .detach(); @@ -328,7 +315,7 @@ impl Sidebar { for workspace in &workspaces { this.subscribe_to_workspace(workspace, window, cx); } - this.update_entries(cx); + this.update_entries(false, cx); }); let persistence_key = multi_workspace.read(cx).database_id().map(|id| id.0); @@ -337,6 +324,7 @@ impl Sidebar { .unwrap_or(false); Self { + _update_entries_task: None, multi_workspace: multi_workspace.downgrade(), persistence_key, is_open, @@ -371,7 +359,7 @@ impl Sidebar { ProjectEvent::WorktreeAdded(_) | ProjectEvent::WorktreeRemoved(_) | ProjectEvent::WorktreeOrderChanged => { - this.update_entries(cx); + this.update_entries(false, cx); } _ => {} }, @@ -392,7 +380,7 @@ impl Sidebar { ) ) { this.prune_stale_worktree_workspaces(window, cx); - this.update_entries(cx); + this.update_entries(false, cx); } }, ) @@ -429,7 +417,7 @@ impl Sidebar { AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ThreadFocused | AgentPanelEvent::BackgroundThreadChanged => { - this.update_entries(cx); + this.update_entries(false, cx); } }, ) @@ -487,7 +475,7 @@ impl Sidebar { .collect() } - fn rebuild_contents(&mut self, cx: &App) { + fn rebuild_contents(&mut self, thread_entries: Vec, cx: &App) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; @@ -501,7 +489,19 @@ impl Sidebar { .and_then(|panel| panel.read(cx).active_connection_view().cloned()) .and_then(|cv| cv.read(cx).parent_id(cx)); - let thread_store = ThreadStore::try_global(cx); + let mut threads_by_paths: HashMap> = HashMap::new(); + for row in thread_entries { + threads_by_paths + .entry(row.folder_paths.clone()) + .or_default() + .push(row); + } + + // Build a lookup for agent icons from the first workspace's AgentServerStore. + let agent_server_store = workspaces + .first() + .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone()); + let query = self.filter_editor.read(cx).text(cx); let previous = mem::take(&mut self.contents); @@ -586,14 +586,35 @@ impl Sidebar { if should_load_threads { let mut seen_session_ids: HashSet = HashSet::new(); - if let Some(ref thread_store) = thread_store { - for meta in thread_store.read(cx).threads_for_paths(&path_list) { - seen_session_ids.insert(meta.id.clone()); + // Read threads from SidebarDb for this workspace's path list. + if let Some(rows) = threads_by_paths.get(&path_list) { + for row in rows { + seen_session_ids.insert(row.session_id.clone()); + let (agent, icon, icon_from_external_svg) = match &row.agent_id { + None => (Agent::NativeAgent, IconName::ZedAgent, None), + Some(id) => { + let custom_icon = agent_server_store + .as_ref() + .and_then(|store| store.read(cx).agent_icon(&id)); + ( + Agent::Custom { id: id.clone() }, + IconName::Terminal, + custom_icon, + ) + } + }; threads.push(ThreadEntry { - agent: Agent::NativeAgent, - session_info: meta.into(), - icon: IconName::ZedAgent, - icon_from_external_svg: None, + agent, + session_info: acp_thread::AgentSessionInfo { + session_id: row.session_id.clone(), + work_dirs: None, + title: Some(row.title.clone()), + updated_at: Some(row.updated_at), + created_at: row.created_at, + meta: None, + }, + icon, + icon_from_external_svg, status: AgentThreadStatus::default(), workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: false, @@ -608,7 +629,7 @@ impl Sidebar { } // Load threads from linked git worktrees of this workspace's repos. - if let Some(ref thread_store) = thread_store { + { let mut linked_worktree_queries: Vec<(PathList, SharedString, Arc)> = Vec::new(); for snapshot in root_repository_snapshots(workspace, cx) { @@ -639,25 +660,52 @@ impl Sidebar { None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()), }; - for meta in thread_store.read(cx).threads_for_paths(worktree_path_list) { - if !seen_session_ids.insert(meta.id.clone()) { - continue; + if let Some(rows) = threads_by_paths.get(worktree_path_list) { + for row in rows { + if !seen_session_ids.insert(row.session_id.clone()) { + continue; + } + let (agent, icon, icon_from_external_svg) = match &row.agent_id { + None => (Agent::NativeAgent, IconName::ZedAgent, None), + Some(name) => { + let custom_icon = + agent_server_store.as_ref().and_then(|store| { + store + .read(cx) + .agent_icon(&AgentId(name.clone().into())) + }); + ( + Agent::Custom { + id: AgentId::new(name.clone()), + }, + IconName::Terminal, + custom_icon, + ) + } + }; + threads.push(ThreadEntry { + agent, + session_info: acp_thread::AgentSessionInfo { + session_id: row.session_id.clone(), + work_dirs: None, + title: Some(row.title.clone()), + updated_at: Some(row.updated_at), + created_at: row.created_at, + meta: None, + }, + icon, + icon_from_external_svg, + status: AgentThreadStatus::default(), + workspace: target_workspace.clone(), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktree_name: Some(worktree_name.clone()), + worktree_highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), + }); } - threads.push(ThreadEntry { - agent: Agent::NativeAgent, - session_info: meta.into(), - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::default(), - workspace: target_workspace.clone(), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: Some(worktree_name.clone()), - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }); } } } @@ -866,7 +914,7 @@ impl Sidebar { }; } - fn update_entries(&mut self, cx: &mut Context) { + fn update_entries(&mut self, select_first_thread: bool, cx: &mut Context) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; @@ -878,18 +926,44 @@ impl Sidebar { let scroll_position = self.list_state.logical_scroll_top(); - self.rebuild_contents(cx); + let list_thread_entries_task = ThreadMetadataStore::global(cx).read(cx).list(cx); - self.list_state.reset(self.contents.entries.len()); - self.list_state.scroll_to(scroll_position); + self._update_entries_task.take(); + self._update_entries_task = Some(cx.spawn(async move |this, cx| { + let Some(thread_entries) = list_thread_entries_task.await.log_err() else { + return; + }; + this.update(cx, |this, cx| { + this.rebuild_contents(thread_entries, cx); - if had_notifications != self.has_notifications(cx) { - multi_workspace.update(cx, |_, cx| { - cx.notify(); - }); - } + if select_first_thread { + this.selection = this + .contents + .entries + .iter() + .position(|entry| matches!(entry, ListEntry::Thread(_))) + .or_else(|| { + if this.contents.entries.is_empty() { + None + } else { + Some(0) + } + }); + } - cx.notify(); + this.list_state.reset(this.contents.entries.len()); + this.list_state.scroll_to(scroll_position); + + if had_notifications != this.has_notifications(cx) { + multi_workspace.update(cx, |_, cx| { + cx.notify(); + }); + } + + cx.notify(); + }) + .ok(); + })); } fn render_list_entry( @@ -1073,7 +1147,7 @@ impl Sidebar { move |this, _, _window, cx| { this.selection = None; this.expanded_groups.remove(&path_list_for_collapse); - this.update_entries(cx); + this.update_entries(false, cx); } })), ) @@ -1279,14 +1353,14 @@ impl Sidebar { } else { self.collapsed_groups.insert(path_list.clone()); } - self.update_entries(cx); + self.update_entries(false, cx); } fn focus_in(&mut self, _window: &mut Window, _cx: &mut Context) {} fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { if self.reset_filter_editor_text(window, cx) { - self.update_entries(cx); + self.update_entries(false, cx); } else { self.focus_handle.focus(window, cx); } @@ -1405,7 +1479,7 @@ impl Sidebar { let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0); self.expanded_groups.insert(path_list, current + 1); } - self.update_entries(cx); + self.update_entries(false, cx); } ListEntry::NewThread { workspace, .. } => { let workspace = workspace.clone(); @@ -1439,7 +1513,7 @@ impl Sidebar { panel.load_agent_thread( agent, session_info.session_id, - session_info.cwd, + session_info.work_dirs, session_info.title, true, window, @@ -1448,7 +1522,7 @@ impl Sidebar { }); } - self.update_entries(cx); + self.update_entries(false, cx); } fn open_workspace_and_activate_thread( @@ -1499,24 +1573,11 @@ impl Sidebar { window: &mut Window, cx: &mut Context, ) { - let saved_path_list = ThreadStore::try_global(cx).and_then(|thread_store| { - thread_store - .read(cx) - .thread_from_session_id(&session_info.session_id) - .map(|thread| thread.folder_paths.clone()) - }); - let path_list = saved_path_list.or_else(|| { - // we don't have saved metadata, so create path list based on the cwd - session_info - .cwd - .as_ref() - .map(|cwd| PathList::new(&[cwd.to_path_buf()])) - }); - - if let Some(path_list) = path_list { + if let Some(path_list) = &session_info.work_dirs { if let Some(workspace) = self.find_open_workspace_for_path_list(&path_list, cx) { self.activate_thread(agent, session_info, &workspace, window, cx); } else { + let path_list = path_list.clone(); self.open_workspace_and_activate_thread(agent, session_info, path_list, window, cx); } return; @@ -1547,7 +1608,7 @@ impl Sidebar { if self.collapsed_groups.contains(path_list) { let path_list = path_list.clone(); self.collapsed_groups.remove(&path_list); - self.update_entries(cx); + self.update_entries(false, cx); } else if ix + 1 < self.contents.entries.len() { self.selection = Some(ix + 1); self.list_state.scroll_to_reveal_item(ix + 1); @@ -1571,7 +1632,7 @@ impl Sidebar { if !self.collapsed_groups.contains(path_list) { let path_list = path_list.clone(); self.collapsed_groups.insert(path_list); - self.update_entries(cx); + self.update_entries(false, cx); } } Some( @@ -1584,7 +1645,7 @@ impl Sidebar { let path_list = path_list.clone(); self.selection = Some(i); self.collapsed_groups.insert(path_list); - self.update_entries(cx); + self.update_entries(false, cx); break; } } @@ -1602,6 +1663,10 @@ impl Sidebar { .delete_thread(session_id.clone(), cx) .detach_and_log_err(cx); }); + + ThreadMetadataStore::global(cx) + .update(cx, |store, cx| store.delete(session_id.clone(), cx)) + .detach_and_log_err(cx); } fn remove_selected_thread( @@ -1807,7 +1872,7 @@ impl Sidebar { let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0); this.expanded_groups.insert(path_list.clone(), current + 1); } - this.update_entries(cx); + this.update_entries(false, cx); })) .into_any_element() } @@ -1899,7 +1964,7 @@ impl Sidebar { .tooltip(Tooltip::text("Clear Search")) .on_click(cx.listener(|this, _, window, cx| { this.reset_filter_editor_text(window, cx); - this.update_entries(cx); + this.update_entries(false, cx); })), ) }) @@ -2153,7 +2218,8 @@ mod tests { use feature_flags::FeatureFlagAppExt as _; use fs::FakeFs; use gpui::TestAppContext; - use std::sync::Arc; + use pretty_assertions::assert_eq; + use std::{path::PathBuf, sync::Arc}; use util::path_list::PathList; fn init_test(cx: &mut TestAppContext) { @@ -2161,32 +2227,12 @@ mod tests { cx.update(|cx| { cx.update_flags(false, vec!["agent-v2".into()]); ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); language_model::LanguageModelRegistry::test(cx); prompt_store::init(cx); }); } - fn make_test_thread(title: &str, updated_at: DateTime) -> agent::DbThread { - agent::DbThread { - title: title.to_string().into(), - messages: Vec::new(), - updated_at, - detailed_summary: None, - initial_project_snapshot: None, - cumulative_token_usage: Default::default(), - request_token_usage: Default::default(), - model: None, - profile: None, - imported: false, - subagent_context: None, - speed: None, - thinking_enabled: false, - thinking_effort: None, - draft_prompt: None, - ui_scroll_position: None, - } - } - async fn init_test_project( worktree_path: &str, cx: &mut TestAppContext, @@ -2237,45 +2283,72 @@ mod tests { path_list: &PathList, cx: &mut gpui::VisualTestContext, ) { - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); for i in 0..count { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(format!("thread-{}", i))), - make_test_thread( - &format!("Thread {}", i + 1), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(format!("thread-{}", i))), + format!("Thread {}", i + 1).into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), + path_list.clone(), + cx, + ) + .await; } cx.run_until_parked(); } - async fn save_thread_to_store( + async fn save_test_thread_metadata( session_id: &acp::SessionId, + path_list: PathList, + cx: &mut TestAppContext, + ) { + save_thread_metadata( + session_id.clone(), + "Test".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list, + cx, + ) + .await; + } + + async fn save_named_thread_metadata( + session_id: &str, + title: &str, path_list: &PathList, cx: &mut gpui::VisualTestContext, ) { - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - session_id.clone(), - make_test_thread( - "Test", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(session_id)), + SharedString::from(title.to_string()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; cx.run_until_parked(); } + async fn save_thread_metadata( + session_id: acp::SessionId, + title: SharedString, + updated_at: DateTime, + path_list: PathList, + cx: &mut TestAppContext, + ) { + let metadata = ThreadMetadata { + session_id, + agent_id: None, + title, + updated_at, + created_at: None, + folder_paths: path_list, + }; + let task = cx.update(|cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)) + }); + task.await.unwrap(); + } + fn open_and_focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { cx.run_until_parked(); sidebar.update_in(cx, |sidebar, window, cx| { @@ -2388,33 +2461,24 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("thread-1")), - make_test_thread( - "Fix crash in project panel", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("thread-2")), - make_test_thread( - "Add inline diff view", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Fix crash in project panel".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-2")), + "Add inline diff view".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; cx.run_until_parked(); multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); @@ -2439,20 +2503,15 @@ mod tests { // Single workspace with a thread let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("thread-a1")), - make_test_thread( - "Thread A1", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-a1")), + "Thread A1".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; cx.run_until_parked(); multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); @@ -2554,7 +2613,7 @@ mod tests { sidebar.update_in(cx, |s, _window, cx| { let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); s.expanded_groups.insert(path_list.clone(), current + 1); - s.update_entries(cx); + s.update_entries(false, cx); }); cx.run_until_parked(); @@ -2567,7 +2626,7 @@ mod tests { sidebar.update_in(cx, |s, _window, cx| { let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); s.expanded_groups.insert(path_list.clone(), current + 1); - s.update_entries(cx); + s.update_entries(false, cx); }); cx.run_until_parked(); @@ -2580,7 +2639,7 @@ mod tests { // Click collapse - should go back to showing 5 threads sidebar.update_in(cx, |s, _window, cx| { s.expanded_groups.remove(&path_list); - s.update_entries(cx); + s.update_entries(false, cx); }); cx.run_until_parked(); @@ -2661,7 +2720,7 @@ mod tests { agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-1")), - cwd: None, + work_dirs: None, title: Some("Completed thread".into()), updated_at: Some(Utc::now()), created_at: Some(Utc::now()), @@ -2684,7 +2743,7 @@ mod tests { agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-2")), - cwd: None, + work_dirs: None, title: Some("Running thread".into()), updated_at: Some(Utc::now()), created_at: Some(Utc::now()), @@ -2707,7 +2766,7 @@ mod tests { agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-3")), - cwd: None, + work_dirs: None, title: Some("Error thread".into()), updated_at: Some(Utc::now()), created_at: Some(Utc::now()), @@ -2730,7 +2789,7 @@ mod tests { agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-4")), - cwd: None, + work_dirs: None, title: Some("Waiting thread".into()), updated_at: Some(Utc::now()), created_at: Some(Utc::now()), @@ -2753,7 +2812,7 @@ mod tests { agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-5")), - cwd: None, + work_dirs: None, title: Some("Notified thread".into()), updated_at: Some(Utc::now()), created_at: Some(Utc::now()), @@ -3213,7 +3272,7 @@ mod tests { send_message(&panel, cx); let session_id_a = active_session_id(&panel, cx); - save_thread_to_store(&session_id_a, &path_list, cx).await; + save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await; cx.update(|_, cx| { connection.send_update( @@ -3232,7 +3291,7 @@ mod tests { send_message(&panel, cx); let session_id_b = active_session_id(&panel, cx); - save_thread_to_store(&session_id_b, &path_list, cx).await; + save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await; cx.run_until_parked(); @@ -3259,7 +3318,7 @@ mod tests { send_message(&panel_a, cx); let session_id_a = active_session_id(&panel_a, cx); - save_thread_to_store(&session_id_a, &path_list_a, cx).await; + save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; cx.update(|_, cx| { connection_a.send_update( @@ -3323,25 +3382,20 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); for (id, title, hour) in [ ("t-1", "Fix crash in project panel", 3), ("t-2", "Add inline diff view", 2), ("t-3", "Refactor settings module", 1), ] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; } cx.run_until_parked(); @@ -3381,20 +3435,15 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("thread-1")), - make_test_thread( - "Fix Crash In Project Panel", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Fix Crash In Project Panel".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; cx.run_until_parked(); // Lowercase query matches mixed-case title. @@ -3428,21 +3477,16 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; } cx.run_until_parked(); @@ -3481,24 +3525,19 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); for (id, title, hour) in [ ("a1", "Fix bug in sidebar", 2), ("a2", "Add tests for editor", 1), ] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list_a.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_a.clone(), + cx, + ) + .await; } // Add a second workspace. @@ -3513,18 +3552,14 @@ mod tests { ("b1", "Refactor sidebar layout", 3), ("b2", "Fix typo in README", 1), ] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list_b.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_b.clone(), + cx, + ) + .await; } cx.run_until_parked(); @@ -3580,24 +3615,19 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); for (id, title, hour) in [ ("a1", "Fix bug in sidebar", 2), ("a2", "Add tests for editor", 1), ] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list_a.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_a.clone(), + cx, + ) + .await; } // Add a second workspace. @@ -3612,18 +3642,14 @@ mod tests { ("b1", "Refactor sidebar layout", 3), ("b2", "Fix typo in README", 1), ] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list_b.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_b.clone(), + cx, + ) + .await; } cx.run_until_parked(); @@ -3701,7 +3727,6 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); // Create 8 threads. The oldest one has a unique name and will be // behind View More (only 5 shown by default). @@ -3711,18 +3736,14 @@ mod tests { } else { format!("Thread {}", i + 1) }; - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(format!("thread-{}", i))), - make_test_thread( - &title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(format!("thread-{}", i))), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), + path_list.clone(), + cx, + ) + .await; } cx.run_until_parked(); @@ -3758,20 +3779,15 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("thread-1")), - make_test_thread( - "Important thread", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Important thread".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; cx.run_until_parked(); // User focuses the sidebar and collapses the group using keyboard: @@ -3804,25 +3820,20 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); for (id, title, hour) in [ ("t-1", "Fix crash in panel", 3), ("t-2", "Fix lint warnings", 2), ("t-3", "Add new feature", 1), ] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; } cx.run_until_parked(); @@ -3876,20 +3887,15 @@ mod tests { cx.run_until_parked(); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("hist-1")), - make_test_thread( - "Historical Thread", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + + save_thread_metadata( + acp::SessionId::new(Arc::from("hist-1")), + "Historical Thread".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; cx.run_until_parked(); multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -3938,32 +3944,25 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("t-1")), - make_test_thread( - "Thread A", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("t-2")), - make_test_thread( - "Thread B", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + + save_thread_metadata( + acp::SessionId::new(Arc::from("t-1")), + "Thread A".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + save_thread_metadata( + acp::SessionId::new(Arc::from("t-2")), + "Thread B".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -4018,7 +4017,7 @@ mod tests { send_message(&panel, cx); let session_id = active_session_id(&panel, cx); - save_thread_to_store(&session_id, &path_list, cx).await; + save_test_thread_metadata(&session_id, path_list.clone(), cx).await; cx.run_until_parked(); assert_eq!( @@ -4066,7 +4065,7 @@ mod tests { open_thread_with_connection(&panel_a, connection_a, cx); send_message(&panel_a, cx); let session_id_a = active_session_id(&panel_a, cx); - save_thread_to_store(&session_id_a, &path_list_a, cx).await; + save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; // Add a second workspace with its own agent panel. let fs = cx.update(|_, cx| ::global(cx)); @@ -4105,7 +4104,7 @@ mod tests { Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: session_id_a.clone(), - cwd: None, + work_dirs: None, title: Some("Test".into()), updated_at: None, created_at: None, @@ -4153,7 +4152,7 @@ mod tests { send_message(&panel_b, cx); let session_id_b = active_session_id(&panel_b, cx); let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); - save_thread_to_store(&session_id_b, &path_list_b, cx).await; + save_test_thread_metadata(&session_id_b, path_list_b.clone(), cx).await; cx.run_until_parked(); // Opening a thread in a non-active workspace should NOT change @@ -4173,7 +4172,7 @@ mod tests { Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: session_id_b.clone(), - cwd: None, + work_dirs: None, title: Some("Thread B".into()), updated_at: None, created_at: None, @@ -4232,7 +4231,7 @@ mod tests { open_thread_with_connection(&panel_b, connection_b2, cx); send_message(&panel_b, cx); let session_id_b2 = active_session_id(&panel_b, cx); - save_thread_to_store(&session_id_b2, &path_list_b, cx).await; + save_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await; cx.run_until_parked(); // Workspace A is still active, so focused_thread stays on session_id_a. @@ -4281,28 +4280,6 @@ mod tests { }); } - async fn save_named_thread( - session_id: &str, - title: &str, - path_list: &PathList, - cx: &mut gpui::VisualTestContext, - ) { - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(session_id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - cx.run_until_parked(); - } - async fn init_test_project_with_git( worktree_path: &str, cx: &mut TestAppContext, @@ -4346,8 +4323,8 @@ mod tests { let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]); let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); - save_named_thread("main-t", "Unrelated Thread", &main_paths, cx).await; - save_named_thread("wt-t", "Fix Bug", &wt_paths, cx).await; + save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await; + save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -4375,7 +4352,7 @@ mod tests { // Save a thread against a worktree path that doesn't exist yet. let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); - save_named_thread("wt-thread", "Worktree Thread", &wt_paths, cx).await; + save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -4467,8 +4444,8 @@ mod tests { let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]); - save_named_thread("thread-a", "Thread A", &paths_a, cx).await; - save_named_thread("thread-b", "Thread B", &paths_b, cx).await; + save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await; + save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -4596,7 +4573,7 @@ mod tests { // Save a thread for the worktree path (no workspace for it). let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread("thread-wt", "WT Thread", &paths_wt, cx).await; + save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -4713,8 +4690,8 @@ mod tests { let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]); let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread("thread-main", "Main Thread", &paths_main, cx).await; - save_named_thread("thread-wt", "WT Thread", &paths_wt, cx).await; + save_named_thread_metadata("thread-main", "Main Thread", &paths_main, cx).await; + save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -4786,7 +4763,7 @@ mod tests { // Save a thread with path_list pointing to project-b. let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); let session_id = acp::SessionId::new(Arc::from("archived-1")); - save_thread_to_store(&session_id, &path_list_b, cx).await; + save_test_thread_metadata(&session_id, path_list_b.clone(), cx).await; // Ensure workspace A is active. multi_workspace.update_in(cx, |mw, window, cx| { @@ -4805,7 +4782,7 @@ mod tests { Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: session_id.clone(), - cwd: Some("/project-b".into()), + work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), title: Some("Archived Thread".into()), updated_at: None, created_at: None, @@ -4866,7 +4843,7 @@ mod tests { Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("unknown-session")), - cwd: Some(std::path::PathBuf::from("/project-b")), + work_dirs: Some(PathList::new(&[std::path::PathBuf::from("/project-b")])), title: Some("CWD Thread".into()), updated_at: None, created_at: None, @@ -4927,7 +4904,7 @@ mod tests { Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("no-context-session")), - cwd: None, + work_dirs: None, title: Some("Contextless Thread".into()), updated_at: None, created_at: None, @@ -4971,7 +4948,6 @@ mod tests { // open workspace. let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); let session_id = acp::SessionId::new(Arc::from("archived-new-ws")); - save_thread_to_store(&session_id, &path_list_b, cx).await; assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), @@ -4984,7 +4960,7 @@ mod tests { Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: session_id.clone(), - cwd: None, + work_dirs: Some(path_list_b), title: Some("New WS Thread".into()), updated_at: None, created_at: None, diff --git a/crates/agent_ui/src/test_support.rs b/crates/agent_ui/src/test_support.rs index 05a6b0925fb9151cc18d7096c8bf4f2674054073..7b986d045dddbf25fbe940a3b783c4f145781e8b 100644 --- a/crates/agent_ui/src/test_support.rs +++ b/crates/agent_ui/src/test_support.rs @@ -1,7 +1,8 @@ use acp_thread::{AgentConnection, StubAgentConnection}; use agent_client_protocol as acp; use agent_servers::{AgentServer, AgentServerDelegate}; -use gpui::{Entity, SharedString, Task, TestAppContext, VisualTestContext}; +use gpui::{Entity, Task, TestAppContext, VisualTestContext}; +use project::AgentId; use settings::SettingsStore; use std::any::Any; use std::rc::Rc; @@ -37,7 +38,7 @@ where ui::IconName::Ai } - fn name(&self) -> SharedString { + fn agent_id(&self) -> AgentId { "Test".into() } diff --git a/crates/agent_ui/src/thread_history.rs b/crates/agent_ui/src/thread_history.rs index 1ca763cb6a64f1d1b680e31c1ac55a4717762157..48d0b11b00103bbcf8399e6f7f77f8804051a465 100644 --- a/crates/agent_ui/src/thread_history.rs +++ b/crates/agent_ui/src/thread_history.rs @@ -408,7 +408,7 @@ mod tests { fn test_session(session_id: &str, title: &str) -> AgentSessionInfo { AgentSessionInfo { session_id: acp::SessionId::new(session_id), - cwd: None, + work_dirs: None, title: Some(title.to_string().into()), updated_at: None, created_at: None, @@ -608,7 +608,7 @@ mod tests { let session_id = acp::SessionId::new("test-session"); let sessions = vec![AgentSessionInfo { session_id: session_id.clone(), - cwd: None, + work_dirs: None, title: Some("Original Title".into()), updated_at: None, created_at: None, @@ -641,7 +641,7 @@ mod tests { let session_id = acp::SessionId::new("test-session"); let sessions = vec![AgentSessionInfo { session_id: session_id.clone(), - cwd: None, + work_dirs: None, title: Some("Original Title".into()), updated_at: None, created_at: None, @@ -671,7 +671,7 @@ mod tests { let session_id = acp::SessionId::new("test-session"); let sessions = vec![AgentSessionInfo { session_id: session_id.clone(), - cwd: None, + work_dirs: None, title: Some("Original Title".into()), updated_at: None, created_at: None, @@ -704,7 +704,7 @@ mod tests { let session_id = acp::SessionId::new("test-session"); let sessions = vec![AgentSessionInfo { session_id: session_id.clone(), - cwd: None, + work_dirs: None, title: None, updated_at: None, created_at: None, @@ -741,7 +741,7 @@ mod tests { let session_id = acp::SessionId::new("test-session"); let sessions = vec![AgentSessionInfo { session_id: session_id.clone(), - cwd: None, + work_dirs: None, title: Some("Server Title".into()), updated_at: None, created_at: None, @@ -775,7 +775,7 @@ mod tests { let session_id = acp::SessionId::new("known-session"); let sessions = vec![AgentSessionInfo { session_id, - cwd: None, + work_dirs: None, title: Some("Original".into()), updated_at: None, created_at: None, diff --git a/crates/agent_ui/src/thread_history_view.rs b/crates/agent_ui/src/thread_history_view.rs index 092169efbf57f2947f2532e4a599e7b4935dc539..6961f78884d2fb5fb95830d91dad940ca9dc48e9 100644 --- a/crates/agent_ui/src/thread_history_view.rs +++ b/crates/agent_ui/src/thread_history_view.rs @@ -755,7 +755,7 @@ impl RenderOnce for HistoryEntryElement { panel.load_agent_thread( agent, entry.session_id.clone(), - entry.cwd.clone(), + entry.work_dirs.clone(), entry.title.clone(), true, window, diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..66a9e05fbb294f253b3b25b782b35f3d503304e4 --- /dev/null +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -0,0 +1,528 @@ +use std::{path::Path, sync::Arc}; + +use agent::{ThreadStore, ZED_AGENT_ID}; +use agent_client_protocol as acp; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use collections::HashMap; +use db::{ + sqlez::{ + bindable::Column, domain::Domain, statement::Statement, + thread_safe_connection::ThreadSafeConnection, + }, + sqlez_macros::sql, +}; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; +use gpui::{AppContext as _, Entity, Global, Subscription, Task}; +use project::AgentId; +use ui::{App, Context, SharedString}; +use workspace::PathList; + +pub fn init(cx: &mut App) { + ThreadMetadataStore::init_global(cx); + + if cx.has_flag::() { + migrate_thread_metadata(cx); + } + cx.observe_flag::(|has_flag, cx| { + if has_flag { + migrate_thread_metadata(cx); + } + }) + .detach(); +} + +/// Migrate existing thread metadata from native agent thread store to the new metadata storage. +/// +/// TODO: Remove this after N weeks of shipping the sidebar +fn migrate_thread_metadata(cx: &mut App) { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + let list = store.list(cx); + cx.spawn(async move |this, cx| { + let Ok(list) = list.await else { + return; + }; + if list.is_empty() { + this.update(cx, |this, cx| { + let metadata = ThreadStore::global(cx) + .read(cx) + .entries() + .map(|entry| ThreadMetadata { + session_id: entry.id, + agent_id: None, + title: entry.title, + updated_at: entry.updated_at, + created_at: entry.created_at, + folder_paths: entry.folder_paths, + }) + .collect::>(); + for entry in metadata { + this.save(entry, cx).detach_and_log_err(cx); + } + }) + .ok(); + } + }) + .detach(); + }); +} + +struct GlobalThreadMetadataStore(Entity); +impl Global for GlobalThreadMetadataStore {} + +/// Lightweight metadata for any thread (native or ACP), enough to populate +/// the sidebar list and route to the correct load path when clicked. +#[derive(Debug, Clone)] +pub struct ThreadMetadata { + pub session_id: acp::SessionId, + /// `None` for native Zed threads, `Some("claude-code")` etc. for ACP agents. + pub agent_id: Option, + pub title: SharedString, + pub updated_at: DateTime, + pub created_at: Option>, + pub folder_paths: PathList, +} + +pub struct ThreadMetadataStore { + db: ThreadMetadataDb, + session_subscriptions: HashMap, +} + +impl ThreadMetadataStore { + #[cfg(not(any(test, feature = "test-support")))] + pub fn init_global(cx: &mut App) { + if cx.has_global::() { + return; + } + + let db = THREAD_METADATA_DB.clone(); + let thread_store = cx.new(|cx| Self::new(db, cx)); + cx.set_global(GlobalThreadMetadataStore(thread_store)); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn init_global(cx: &mut App) { + let thread = std::thread::current(); + let test_name = thread.name().unwrap_or("unknown_test"); + let db_name = format!("THREAD_METADATA_DB_{}", test_name); + let db = smol::block_on(db::open_test_db::(&db_name)); + let thread_store = cx.new(|cx| Self::new(ThreadMetadataDb(db), cx)); + cx.set_global(GlobalThreadMetadataStore(thread_store)); + } + + pub fn try_global(cx: &App) -> Option> { + cx.try_global::() + .map(|store| store.0.clone()) + } + + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + pub fn list(&self, cx: &App) -> Task>> { + let db = self.db.clone(); + cx.background_spawn(async move { + let s = db.list()?; + Ok(s) + }) + } + + pub fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context) -> Task> { + if !cx.has_flag::() { + return Task::ready(Ok(())); + } + + let db = self.db.clone(); + cx.spawn(async move |this, cx| { + db.save(metadata).await?; + this.update(cx, |_this, cx| cx.notify()) + }) + } + + pub fn delete( + &mut self, + session_id: acp::SessionId, + cx: &mut Context, + ) -> Task> { + if !cx.has_flag::() { + return Task::ready(Ok(())); + } + + let db = self.db.clone(); + cx.spawn(async move |this, cx| { + db.delete(session_id).await?; + this.update(cx, |_this, cx| cx.notify()) + }) + } + + fn new(db: ThreadMetadataDb, cx: &mut Context) -> Self { + let weak_store = cx.weak_entity(); + + cx.observe_new::(move |thread, _window, cx| { + let thread_entity = cx.entity(); + + cx.on_release({ + let weak_store = weak_store.clone(); + move |thread, cx| { + weak_store + .update(cx, |store, _cx| { + store.session_subscriptions.remove(thread.session_id()); + }) + .ok(); + } + }) + .detach(); + + weak_store + .update(cx, |this, cx| { + let subscription = cx.subscribe(&thread_entity, Self::handle_thread_update); + this.session_subscriptions + .insert(thread.session_id().clone(), subscription); + }) + .ok(); + }) + .detach(); + + Self { + db, + session_subscriptions: HashMap::default(), + } + } + + fn handle_thread_update( + &mut self, + thread: Entity, + event: &acp_thread::AcpThreadEvent, + cx: &mut Context, + ) { + match event { + acp_thread::AcpThreadEvent::NewEntry + | acp_thread::AcpThreadEvent::EntryUpdated(_) + | acp_thread::AcpThreadEvent::TitleUpdated => { + let metadata = Self::metadata_for_acp_thread(thread.read(cx), cx); + self.save(metadata, cx).detach_and_log_err(cx); + } + _ => {} + } + } + + fn metadata_for_acp_thread(thread: &acp_thread::AcpThread, cx: &App) -> ThreadMetadata { + let session_id = thread.session_id().clone(); + let title = thread.title(); + let updated_at = Utc::now(); + + let agent_id = thread.connection().agent_id(); + + let agent_id = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() { + None + } else { + Some(agent_id) + }; + + let folder_paths = { + let project = thread.project().read(cx); + let paths: Vec> = project + .visible_worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path()) + .collect(); + PathList::new(&paths) + }; + + ThreadMetadata { + session_id, + agent_id, + title, + created_at: Some(updated_at), // handled by db `ON CONFLICT` + updated_at, + folder_paths, + } + } +} + +impl Global for ThreadMetadataStore {} + +#[derive(Clone)] +struct ThreadMetadataDb(ThreadSafeConnection); + +impl Domain for ThreadMetadataDb { + const NAME: &str = stringify!(ThreadMetadataDb); + + const MIGRATIONS: &[&str] = &[sql!( + CREATE TABLE IF NOT EXISTS sidebar_threads( + session_id TEXT PRIMARY KEY, + agent_id TEXT, + title TEXT NOT NULL, + updated_at TEXT NOT NULL, + created_at TEXT, + folder_paths TEXT, + folder_paths_order TEXT + ) STRICT; + )]; +} + +db::static_connection!(THREAD_METADATA_DB, ThreadMetadataDb, []); + +impl ThreadMetadataDb { + /// List all sidebar thread metadata, ordered by updated_at descending. + pub fn list(&self) -> anyhow::Result> { + self.select::( + "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order \ + FROM sidebar_threads \ + ORDER BY updated_at DESC" + )?() + } + + /// Upsert metadata for a thread. + pub async fn save(&self, row: ThreadMetadata) -> anyhow::Result<()> { + let id = row.session_id.0.clone(); + let agent_id = row.agent_id.as_ref().map(|id| id.0.to_string()); + let title = row.title.to_string(); + let updated_at = row.updated_at.to_rfc3339(); + let created_at = row.created_at.map(|dt| dt.to_rfc3339()); + let serialized = row.folder_paths.serialize(); + let (folder_paths, folder_paths_order) = if row.folder_paths.is_empty() { + (None, None) + } else { + (Some(serialized.paths), Some(serialized.order)) + }; + + self.write(move |conn| { + let sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + ON CONFLICT(session_id) DO UPDATE SET \ + agent_id = excluded.agent_id, \ + title = excluded.title, \ + updated_at = excluded.updated_at, \ + folder_paths = excluded.folder_paths, \ + folder_paths_order = excluded.folder_paths_order"; + let mut stmt = Statement::prepare(conn, sql)?; + let mut i = stmt.bind(&id, 1)?; + i = stmt.bind(&agent_id, i)?; + i = stmt.bind(&title, i)?; + i = stmt.bind(&updated_at, i)?; + i = stmt.bind(&created_at, i)?; + i = stmt.bind(&folder_paths, i)?; + stmt.bind(&folder_paths_order, i)?; + stmt.exec() + }) + .await + } + + /// Delete metadata for a single thread. + pub async fn delete(&self, session_id: acp::SessionId) -> anyhow::Result<()> { + let id = session_id.0.clone(); + self.write(move |conn| { + let mut stmt = + Statement::prepare(conn, "DELETE FROM sidebar_threads WHERE session_id = ?")?; + stmt.bind(&id, 1)?; + stmt.exec() + }) + .await + } +} + +impl Column for ThreadMetadata { + fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> { + let (id, next): (Arc, i32) = Column::column(statement, start_index)?; + let (agent_id, next): (Option, i32) = Column::column(statement, next)?; + let (title, next): (String, i32) = Column::column(statement, next)?; + let (updated_at_str, next): (String, i32) = Column::column(statement, next)?; + let (created_at_str, next): (Option, i32) = Column::column(statement, next)?; + let (folder_paths_str, next): (Option, i32) = Column::column(statement, next)?; + let (folder_paths_order_str, next): (Option, i32) = + Column::column(statement, next)?; + + let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc); + let created_at = created_at_str + .as_deref() + .map(DateTime::parse_from_rfc3339) + .transpose()? + .map(|dt| dt.with_timezone(&Utc)); + + let folder_paths = folder_paths_str + .map(|paths| { + PathList::deserialize(&util::path_list::SerializedPathList { + paths, + order: folder_paths_order_str.unwrap_or_default(), + }) + }) + .unwrap_or_default(); + + Ok(( + ThreadMetadata { + session_id: acp::SessionId::new(id), + agent_id: agent_id.map(|id| AgentId::new(id)), + title: title.into(), + updated_at, + created_at, + folder_paths, + }, + next, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use agent::DbThread; + use gpui::TestAppContext; + + fn make_db_thread(title: &str, updated_at: DateTime) -> DbThread { + DbThread { + title: title.to_string().into(), + messages: Vec::new(), + updated_at, + detailed_summary: None, + initial_project_snapshot: None, + cumulative_token_usage: Default::default(), + request_token_usage: Default::default(), + model: None, + profile: None, + imported: false, + subagent_context: None, + speed: None, + thinking_enabled: false, + thinking_effort: None, + draft_prompt: None, + ui_scroll_position: None, + } + } + + #[gpui::test] + async fn test_migrate_thread_metadata(cx: &mut TestAppContext) { + cx.update(|cx| { + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + }); + + // Verify the list is empty before migration + let metadata_list = cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.read(cx).list(cx) + }); + + let list = metadata_list.await.unwrap(); + assert_eq!(list.len(), 0); + + let now = Utc::now(); + + // Populate the native ThreadStore via save_thread + let save1 = cx.update(|cx| { + let thread_store = ThreadStore::global(cx); + thread_store.update(cx, |store, cx| { + store.save_thread( + acp::SessionId::new("session-1"), + make_db_thread("Thread 1", now), + PathList::default(), + cx, + ) + }) + }); + save1.await.unwrap(); + cx.run_until_parked(); + + let save2 = cx.update(|cx| { + let thread_store = ThreadStore::global(cx); + thread_store.update(cx, |store, cx| { + store.save_thread( + acp::SessionId::new("session-2"), + make_db_thread("Thread 2", now), + PathList::default(), + cx, + ) + }) + }); + save2.await.unwrap(); + cx.run_until_parked(); + + // Run migration + cx.update(|cx| { + migrate_thread_metadata(cx); + }); + + cx.run_until_parked(); + + // Verify the metadata was migrated + let metadata_list = cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.read(cx).list(cx) + }); + + let list = metadata_list.await.unwrap(); + assert_eq!(list.len(), 2); + + let metadata1 = list + .iter() + .find(|m| m.session_id.0.as_ref() == "session-1") + .expect("session-1 should be in migrated metadata"); + assert_eq!(metadata1.title.as_ref(), "Thread 1"); + assert!(metadata1.agent_id.is_none()); + + let metadata2 = list + .iter() + .find(|m| m.session_id.0.as_ref() == "session-2") + .expect("session-2 should be in migrated metadata"); + assert_eq!(metadata2.title.as_ref(), "Thread 2"); + assert!(metadata2.agent_id.is_none()); + } + + #[gpui::test] + async fn test_migrate_thread_metadata_skips_when_data_exists(cx: &mut TestAppContext) { + cx.update(|cx| { + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + }); + + // Pre-populate the metadata store with existing data + let existing_metadata = ThreadMetadata { + session_id: acp::SessionId::new("existing-session"), + agent_id: None, + title: "Existing Thread".into(), + updated_at: Utc::now(), + created_at: Some(Utc::now()), + folder_paths: PathList::default(), + }; + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.save(existing_metadata, cx).detach(); + }); + }); + + cx.run_until_parked(); + + // Add an entry to native thread store that should NOT be migrated + let save_task = cx.update(|cx| { + let thread_store = ThreadStore::global(cx); + thread_store.update(cx, |store, cx| { + store.save_thread( + acp::SessionId::new("native-session"), + make_db_thread("Native Thread", Utc::now()), + PathList::default(), + cx, + ) + }) + }); + save_task.await.unwrap(); + cx.run_until_parked(); + + // Run migration - should skip because metadata store is not empty + cx.update(|cx| { + migrate_thread_metadata(cx); + }); + + cx.run_until_parked(); + + // Verify only the existing metadata is present (migration was skipped) + let metadata_list = cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.read(cx).list(cx) + }); + + let list = metadata_list.await.unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].session_id.0.as_ref(), "existing-session"); + } +} diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index ce5cae4830be732cbc6ca0156d61eb3c48dae888..237a6c539c6669df0df535ae91a7ba9fa99acf9f 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -16,7 +16,7 @@ use gpui::{ }; use itertools::Itertools as _; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; -use project::{AgentServerStore, ExternalAgentServerName}; +use project::{AgentId, AgentServerStore}; use theme::ActiveTheme; use ui::{ ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, HighlightedLabel, ListItem, @@ -530,9 +530,9 @@ impl ThreadsArchiveView { (IconName::ChevronDown, Color::Muted) }; - let selected_agent_icon = if let Agent::Custom { name } = &self.selected_agent { + let selected_agent_icon = if let Agent::Custom { id } = &self.selected_agent { let store = agent_server_store.read(cx); - let icon = store.agent_icon(&ExternalAgentServerName(name.clone())); + let icon = store.agent_icon(&id); if let Some(icon) = icon { Icon::from_external_svg(icon) @@ -584,24 +584,24 @@ impl ThreadsArchiveView { let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx)); struct AgentMenuItem { - id: ExternalAgentServerName, + id: AgentId, display_name: SharedString, } let agent_items = agent_server_store .external_agents() - .map(|name| { + .map(|agent_id| { let display_name = agent_server_store - .agent_display_name(name) + .agent_display_name(agent_id) .or_else(|| { registry_store_ref .as_ref() - .and_then(|store| store.agent(name.0.as_ref())) + .and_then(|store| store.agent(agent_id)) .map(|a| a.name().clone()) }) - .unwrap_or_else(|| name.0.clone()); + .unwrap_or_else(|| agent_id.0.clone()); AgentMenuItem { - id: name.clone(), + id: agent_id.clone(), display_name, } }) @@ -614,7 +614,7 @@ impl ThreadsArchiveView { let icon_path = agent_server_store.agent_icon(&item.id).or_else(|| { registry_store_ref .as_ref() - .and_then(|store| store.agent(item.id.0.as_str())) + .and_then(|store| store.agent(&item.id)) .and_then(|a| a.icon_path().cloned()) }); @@ -627,7 +627,7 @@ impl ThreadsArchiveView { entry = entry.icon_color(Color::Muted).handler({ let this = this.clone(); let agent = Agent::Custom { - name: item.id.0.clone(), + id: item.id.clone(), }; move |window, cx| { this.update(cx, |this, cx| { diff --git a/crates/agent_ui/src/ui/acp_onboarding_modal.rs b/crates/agent_ui/src/ui/acp_onboarding_modal.rs index ee214e07ffb526f1c4ef89cc9301b4ea7e8d6ebf..7b6a563582abe89022d9d1684275dc850d28b23b 100644 --- a/crates/agent_ui/src/ui/acp_onboarding_modal.rs +++ b/crates/agent_ui/src/ui/acp_onboarding_modal.rs @@ -2,7 +2,7 @@ use gpui::{ ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render, linear_color_stop, linear_gradient, }; -use project::agent_server_store::GEMINI_NAME; +use project::agent_server_store::GEMINI_ID; use ui::{TintColor, Vector, VectorName, prelude::*}; use workspace::{ModalView, Workspace}; @@ -39,7 +39,7 @@ impl AcpOnboardingModal { panel.update(cx, |panel, cx| { panel.new_agent_thread( AgentType::Custom { - name: GEMINI_NAME.into(), + id: GEMINI_ID.into(), }, window, cx, diff --git a/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs b/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs index 3a9010b0a155873e658946b4155f09f8867e498a..c8ae51850325d674ae45eac22891cdcd0c948465 100644 --- a/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs +++ b/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs @@ -2,7 +2,7 @@ use gpui::{ ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render, linear_color_stop, linear_gradient, }; -use project::agent_server_store::CLAUDE_AGENT_NAME; +use project::agent_server_store::CLAUDE_AGENT_ID; use ui::{TintColor, Vector, VectorName, prelude::*}; use workspace::{ModalView, Workspace}; @@ -39,7 +39,7 @@ impl ClaudeCodeOnboardingModal { panel.update(cx, |panel, cx| { panel.new_agent_thread( AgentType::Custom { - name: CLAUDE_AGENT_NAME.into(), + id: CLAUDE_AGENT_ID.into(), }, window, cx, diff --git a/crates/eval_cli/src/main.rs b/crates/eval_cli/src/main.rs index 7b9f822a539c8d1e0a29bdef0bccee5d4a55721e..b49cc4d53f50eeb5ea10216867257332c5354cb4 100644 --- a/crates/eval_cli/src/main.rs +++ b/crates/eval_cli/src/main.rs @@ -50,6 +50,7 @@ use gpui::{AppContext as _, AsyncApp, Entity, UpdateGlobal}; use language_model::{LanguageModelRegistry, SelectedModel}; use project::Project; use settings::SettingsStore; +use util::path_list::PathList; use crate::headless::AgentCliAppState; @@ -370,7 +371,11 @@ async fn run_agent( let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = match cx - .update(|cx| connection.clone().new_session(project, workdir, cx)) + .update(|cx| { + connection + .clone() + .new_session(project, PathList::new(&[workdir]), cx) + }) .await { Ok(t) => t, diff --git a/crates/project/src/agent_registry_store.rs b/crates/project/src/agent_registry_store.rs index 79d6e52097d17cadc0271cb09de4ab283c6d93b8..b0a7e965f093afead16e2e9f2b5f7df44298a314 100644 --- a/crates/project/src/agent_registry_store.rs +++ b/crates/project/src/agent_registry_store.rs @@ -11,14 +11,14 @@ use http_client::{AsyncBody, HttpClient}; use serde::Deserialize; use settings::Settings as _; -use crate::DisableAiSettings; +use crate::{AgentId, DisableAiSettings}; const REGISTRY_URL: &str = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; const REFRESH_THROTTLE_DURATION: Duration = Duration::from_secs(60 * 60); #[derive(Clone, Debug)] pub struct RegistryAgentMetadata { - pub id: SharedString, + pub id: AgentId, pub name: SharedString, pub description: SharedString, pub version: SharedString, @@ -55,7 +55,7 @@ impl RegistryAgent { } } - pub fn id(&self) -> &SharedString { + pub fn id(&self) -> &AgentId { &self.metadata().id } @@ -167,8 +167,8 @@ impl AgentRegistryStore { &self.agents } - pub fn agent(&self, id: &str) -> Option<&RegistryAgent> { - self.agents.iter().find(|agent| agent.id().as_ref() == id) + pub fn agent(&self, id: &AgentId) -> Option<&RegistryAgent> { + self.agents.iter().find(|agent| agent.id() == id) } pub fn is_fetching(&self) -> bool { @@ -364,7 +364,7 @@ async fn build_registry_agents( .await?; let metadata = RegistryAgentMetadata { - id: entry.id.into(), + id: AgentId::new(entry.id), name: entry.name.into(), description: entry.description.into(), version: entry.version.into(), diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index 4a7c2b03a4e03ddfa31bed24254ebe275a17c224..d5acacb912d085121c4c370046c9c7bd734c817c 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -61,28 +61,43 @@ impl std::fmt::Debug for AgentServerCommand { } } -#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct ExternalAgentServerName(pub SharedString); +#[derive( + Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(transparent)] +pub struct AgentId(pub SharedString); + +impl AgentId { + pub fn new(id: impl Into) -> Self { + AgentId(id.into()) + } +} -impl std::fmt::Display for ExternalAgentServerName { +impl std::fmt::Display for AgentId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } -impl From<&'static str> for ExternalAgentServerName { +impl From<&'static str> for AgentId { fn from(value: &'static str) -> Self { - ExternalAgentServerName(value.into()) + AgentId(value.into()) } } -impl From for SharedString { - fn from(value: ExternalAgentServerName) -> Self { +impl From for SharedString { + fn from(value: AgentId) -> Self { value.0 } } -impl std::borrow::Borrow for ExternalAgentServerName { +impl AsRef for AgentId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl std::borrow::Borrow for AgentId { fn borrow(&self) -> &str { &self.0 } @@ -163,7 +178,7 @@ impl ExternalAgentEntry { pub struct AgentServerStore { state: AgentServerStoreState, - pub external_agents: HashMap, + pub external_agents: HashMap, } pub struct AgentServersUpdated; @@ -228,7 +243,7 @@ impl AgentServerStore { .as_ref() .map(|path| SharedString::from(path.clone())); let icon = icon_path; - let agent_server_name = ExternalAgentServerName(agent_name.clone().into()); + let agent_server_name = AgentId(agent_name.clone().into()); self.external_agents .entry(agent_server_name.clone()) .and_modify(|entry| { @@ -285,13 +300,13 @@ impl AgentServerStore { cx.emit(AgentServersUpdated); } - pub fn agent_icon(&self, name: &ExternalAgentServerName) -> Option { + pub fn agent_icon(&self, name: &AgentId) -> Option { self.external_agents .get(name) .and_then(|entry| entry.icon.clone()) } - pub fn agent_source(&self, name: &ExternalAgentServerName) -> Option { + pub fn agent_source(&self, name: &AgentId) -> Option { self.external_agents.get(name).map(|entry| entry.source) } } @@ -337,7 +352,7 @@ pub fn resolve_extension_icon_path( } impl AgentServerStore { - pub fn agent_display_name(&self, name: &ExternalAgentServerName) -> Option { + pub fn agent_display_name(&self, name: &AgentId) -> Option { self.external_agents .get(name) .and_then(|entry| entry.display_name.clone()) @@ -424,7 +439,7 @@ impl AgentServerStore { // Insert extension agents before custom/registry so registry entries override extensions. for (agent_name, ext_id, targets, env, icon_path, display_name) in extension_agents.iter() { - let name = ExternalAgentServerName(agent_name.clone().into()); + let name = AgentId(agent_name.clone().into()); let mut env = env.clone(); if let Some(settings_env) = new_settings @@ -463,7 +478,7 @@ impl AgentServerStore { for (name, settings) in new_settings.iter() { match settings { CustomAgentServerSettings::Custom { command, .. } => { - let agent_name = ExternalAgentServerName(name.clone().into()); + let agent_name = AgentId(name.clone().into()); self.external_agents.insert( agent_name.clone(), ExternalAgentEntry::new( @@ -485,7 +500,7 @@ impl AgentServerStore { continue; }; - let agent_name = ExternalAgentServerName(name.clone().into()); + let agent_name = AgentId(name.clone().into()); match agent { RegistryAgent::Binary(agent) => { if !agent.supports_current_platform { @@ -650,7 +665,7 @@ impl AgentServerStore { pub fn get_external_agent( &mut self, - name: &ExternalAgentServerName, + name: &AgentId, ) -> Option<&mut (dyn ExternalAgentServer + 'static)> { self.external_agents .get_mut(name) @@ -668,7 +683,7 @@ impl AgentServerStore { } } - pub fn external_agents(&self) -> impl Iterator { + pub fn external_agents(&self) -> impl Iterator { self.external_agents.keys() } @@ -777,12 +792,12 @@ impl AgentServerStore { .names .into_iter() .map(|name| { - let agent_name = ExternalAgentServerName(name.into()); + let agent_id = AgentId(name.into()); let (icon, display_name, source) = metadata - .remove(&agent_name) + .remove(&agent_id) .or_else(|| { AgentRegistryStore::try_global(cx) - .and_then(|store| store.read(cx).agent(&agent_name.0)) + .and_then(|store| store.read(cx).agent(&agent_id)) .map(|s| { ( s.icon_path().cloned(), @@ -795,13 +810,13 @@ impl AgentServerStore { let agent = RemoteExternalAgentServer { project_id: *project_id, upstream_client: upstream_client.clone(), - name: agent_name.clone(), + name: agent_id.clone(), new_version_available_tx: new_version_available_txs - .remove(&agent_name) + .remove(&agent_id) .flatten(), }; ( - agent_name, + agent_id, ExternalAgentEntry::new( Box::new(agent) as Box, source, @@ -877,10 +892,7 @@ impl AgentServerStore { Ok(()) } - pub fn get_extension_id_for_agent( - &mut self, - name: &ExternalAgentServerName, - ) -> Option> { + pub fn get_extension_id_for_agent(&mut self, name: &AgentId) -> Option> { self.external_agents.get_mut(name).and_then(|entry| { entry .server @@ -894,7 +906,7 @@ impl AgentServerStore { struct RemoteExternalAgentServer { project_id: u64, upstream_client: Entity, - name: ExternalAgentServerName, + name: AgentId, new_version_available_tx: Option>>, } @@ -1434,9 +1446,9 @@ impl ExternalAgentServer for LocalCustomAgent { } } -pub const GEMINI_NAME: &str = "gemini"; -pub const CLAUDE_AGENT_NAME: &str = "claude-acp"; -pub const CODEX_NAME: &str = "codex-acp"; +pub const GEMINI_ID: &str = "gemini"; +pub const CLAUDE_AGENT_ID: &str = "claude-acp"; +pub const CODEX_ID: &str = "codex-acp"; #[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)] pub struct AllAgentServersSettings(pub HashMap); diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index ed8884cd68c6df32375686dd5ceb41b21cbb5cdd..d26f60350b5656b1730993ff76e07c31139c41da 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -43,9 +43,7 @@ use crate::{ worktree_store::WorktreeIdCounter, }; pub use agent_registry_store::{AgentRegistryStore, RegistryAgent}; -pub use agent_server_store::{ - AgentServerStore, AgentServersUpdated, ExternalAgentServerName, ExternalAgentSource, -}; +pub use agent_server_store::{AgentId, AgentServerStore, AgentServersUpdated, ExternalAgentSource}; pub use git_store::{ ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate, git_traversal::{ChildEntriesGitIter, GitEntry, GitEntryRef, GitTraversal}, diff --git a/crates/project/tests/integration/ext_agent_tests.rs b/crates/project/tests/integration/ext_agent_tests.rs index 40961cd0267db9effc897376de9531d5ceb6f463..38da460023ebb6c4d24dd02f21928db7e3cd54e3 100644 --- a/crates/project/tests/integration/ext_agent_tests.rs +++ b/crates/project/tests/integration/ext_agent_tests.rs @@ -27,7 +27,7 @@ impl ExternalAgentServer for NoopExternalAgent { #[test] fn external_agent_server_name_display() { - let name = ExternalAgentServerName(SharedString::from("Ext: Tool")); + let name = AgentId(SharedString::from("Ext: Tool")); let mut s = String::new(); write!(&mut s, "{name}").unwrap(); assert_eq!(s, "Ext: Tool"); @@ -39,7 +39,7 @@ fn sync_extension_agents_removes_previous_extension_entries() { // Seed with a couple of agents that will be replaced by extensions store.external_agents.insert( - ExternalAgentServerName(SharedString::from("foo-agent")), + AgentId(SharedString::from("foo-agent")), ExternalAgentEntry::new( Box::new(NoopExternalAgent) as Box, ExternalAgentSource::Custom, @@ -48,7 +48,7 @@ fn sync_extension_agents_removes_previous_extension_entries() { ), ); store.external_agents.insert( - ExternalAgentServerName(SharedString::from("bar-agent")), + AgentId(SharedString::from("bar-agent")), ExternalAgentEntry::new( Box::new(NoopExternalAgent) as Box, ExternalAgentSource::Custom, @@ -57,7 +57,7 @@ fn sync_extension_agents_removes_previous_extension_entries() { ), ); store.external_agents.insert( - ExternalAgentServerName(SharedString::from("custom")), + AgentId(SharedString::from("custom")), ExternalAgentEntry::new( Box::new(NoopExternalAgent) as Box, ExternalAgentSource::Custom, diff --git a/crates/project/tests/integration/extension_agent_tests.rs b/crates/project/tests/integration/extension_agent_tests.rs index b45f76fbd6835f0cf94f8622df10c2eee3b3c9d3..1824fbec0d172e2bac626e726d305883818d51ad 100644 --- a/crates/project/tests/integration/extension_agent_tests.rs +++ b/crates/project/tests/integration/extension_agent_tests.rs @@ -9,14 +9,14 @@ use std::{any::Any, path::PathBuf, sync::Arc}; #[test] fn extension_agent_constructs_proper_display_names() { // Verify the display name format for extension-provided agents - let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent")); + let name1 = AgentId(SharedString::from("Extension: Agent")); assert!(name1.0.contains(": ")); - let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent")); + let name2 = AgentId(SharedString::from("MyExt: MyAgent")); assert_eq!(name2.0, "MyExt: MyAgent"); // Non-extension agents shouldn't have the separator - let custom = ExternalAgentServerName(SharedString::from("custom")); + let custom = AgentId(SharedString::from("custom")); assert!(!custom.0.contains(": ")); } @@ -47,7 +47,7 @@ fn sync_removes_only_extension_provided_agents() { // Seed with extension agents (contain ": ") and custom agents (don't contain ": ") store.external_agents.insert( - ExternalAgentServerName(SharedString::from("Ext1: Agent1")), + AgentId(SharedString::from("Ext1: Agent1")), ExternalAgentEntry::new( Box::new(NoopExternalAgent) as Box, ExternalAgentSource::Extension, @@ -56,7 +56,7 @@ fn sync_removes_only_extension_provided_agents() { ), ); store.external_agents.insert( - ExternalAgentServerName(SharedString::from("Ext2: Agent2")), + AgentId(SharedString::from("Ext2: Agent2")), ExternalAgentEntry::new( Box::new(NoopExternalAgent) as Box, ExternalAgentSource::Extension, @@ -65,7 +65,7 @@ fn sync_removes_only_extension_provided_agents() { ), ); store.external_agents.insert( - ExternalAgentServerName(SharedString::from("custom-agent")), + AgentId(SharedString::from("custom-agent")), ExternalAgentEntry::new( Box::new(NoopExternalAgent) as Box, ExternalAgentSource::Custom, @@ -84,7 +84,7 @@ fn sync_removes_only_extension_provided_agents() { assert!( store .external_agents - .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent"))) + .contains_key(&AgentId(SharedString::from("custom-agent"))) ); } @@ -117,7 +117,7 @@ fn archive_launcher_constructs_with_all_fields() { }; // Verify display name construction - let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent")); + let expected_name = AgentId(SharedString::from("GitHub Agent")); assert_eq!(expected_name.0, "GitHub Agent"); } @@ -170,7 +170,7 @@ async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAp fn sync_extension_agents_registers_archive_launcher() { use extension::AgentServerManifestEntry; - let expected_name = ExternalAgentServerName(SharedString::from("Release Agent")); + let expected_name = AgentId(SharedString::from("Release Agent")); assert_eq!(expected_name.0, "Release Agent"); // Verify the manifest entry structure for archive-based installation diff --git a/crates/util/src/path_list.rs b/crates/util/src/path_list.rs index 7d605c7924a7d9c25a89634ca7339a457fb99ae4..bd012e43dd0c073d78822a5e831af1d78503e8ab 100644 --- a/crates/util/src/path_list.rs +++ b/crates/util/src/path_list.rs @@ -5,7 +5,7 @@ use std::{ use crate::paths::SanitizedPath; use itertools::Itertools; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde::{Deserialize, Serialize}; /// A list of absolute paths, in a specific order. /// @@ -23,7 +23,7 @@ pub struct PathList { order: Arc<[usize]>, } -#[derive(Debug)] +#[derive(Debug, Serialize, Deserialize)] pub struct SerializedPathList { pub paths: String, pub order: String, @@ -119,19 +119,6 @@ impl PathList { } } -impl Serialize for PathList { - fn serialize(&self, serializer: S) -> Result { - self.paths.serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for PathList { - fn deserialize>(deserializer: D) -> Result { - let paths: Vec = Vec::deserialize(deserializer)?; - Ok(PathList::new(&paths)) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 37642b012edcd133dfe770a4c57c5404658582b5..310632648053036162491a54d346eb5c98f13994 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -103,10 +103,11 @@ use { feature_flags::FeatureFlagAppExt as _, git_ui::project_diff::ProjectDiff, gpui::{ - Action as _, App, AppContext as _, Bounds, KeyBinding, Modifiers, SharedString, - VisualTestAppContext, WindowBounds, WindowHandle, WindowOptions, point, px, size, + Action as _, App, AppContext as _, Bounds, KeyBinding, Modifiers, VisualTestAppContext, + WindowBounds, WindowHandle, WindowOptions, point, px, size, }, image::RgbaImage, + project::AgentId, project_panel::ProjectPanel, settings::{NotifyWhenAgentWaiting, Settings as _}, settings_ui::SettingsWindow, @@ -1958,7 +1959,7 @@ impl AgentServer for StubAgentServer { ui::IconName::ZedAssistant } - fn name(&self) -> SharedString { + fn agent_id(&self) -> AgentId { "Visual Test Agent".into() } diff --git a/docs/acp-threads-in-sidebar-plan.md b/docs/acp-threads-in-sidebar-plan.md new file mode 100644 index 0000000000000000000000000000000000000000..e4a23418d49bb3ad7cd688f5110341edc5c3abf2 --- /dev/null +++ b/docs/acp-threads-in-sidebar-plan.md @@ -0,0 +1,580 @@ +# Plan: Show ACP Threads in the Sidebar (Revised) + +## Problem + +The sidebar currently only shows **Zed-native agent threads** (from `ThreadStore`/`ThreadsDatabase`). ACP threads (Claude Code, Codex, Gemini, etc.) are invisible in the sidebar once they're no longer live. + +## Root Cause + +`ThreadStore` and `ThreadsDatabase` only persist metadata for native threads. When `rebuild_contents` populates the sidebar, it reads from `ThreadStore` for historical threads and overlays live info from the `AgentPanel` — but non-native threads never get written to `ThreadStore`, so once they stop being live, they disappear. + +## Solution Overview (Revised) + +**Key change from the original plan:** We completely remove the sidebar's dependency on `ThreadStore`. Instead, the `Sidebar` itself owns a **single, unified persistence layer** — a new `SidebarDb` domain stored in the workspace DB — that tracks metadata for _all_ thread types (native and ACP). The sidebar becomes the single source of truth for what threads appear in the list. + +### Why Remove the ThreadStore Dependency? + +1. **Single responsibility** — The sidebar is the only consumer of "which threads to show in the list." Having it depend on `ThreadStore` (which exists primarily for native agent save/load) creates an indirect coupling that makes ACP integration awkward. +2. **No merge logic** — The original plan required merging native `ThreadStore` data with a separate `AcpThreadMetadataDb` in `ThreadStore::reload`. By moving all sidebar metadata into one place, there's nothing to merge. +3. **Simpler data flow** — Writers (native agent, ACP connections) push metadata to the sidebar DB. The sidebar reads from one table. No cross-crate coordination needed. +4. **ThreadStore stays focused** — `ThreadStore` continues to manage native thread blob storage (save/load message data) without being polluted with sidebar display concerns. + +### Architecture + +``` + ┌─────────────────────┐ ┌─────────────────────────┐ + │ NativeAgent │ │ ACP Connections │ + │ (on save_thread) │ │ (on create/update/list) │ + └──────────┬──────────┘ └──────────┬──────────────┘ + │ │ + │ save_sidebar_thread() │ + └──────────┬─────────────────┘ + ▼ + ┌───────────────────┐ + │ SidebarDb │ + │ (workspace DB) │ + │ sidebar_threads │ + └────────┬──────────┘ + │ + ▼ + ┌───────────────────┐ + │ Sidebar │ + │ rebuild_contents │ + └───────────────────┘ +``` + +--- + +## Step 1: Create `SidebarDb` Domain in `sidebar.rs` + +**File:** `crates/agent_ui/src/sidebar.rs` + +Add a `SidebarDb` domain using `db::static_connection!`, co-located in the sidebar module (or a small `persistence` submodule within `sidebar.rs` if it helps organization, but keeping it in the same file is fine for now). + +### Schema + +```rust +use db::{ + sqlez::{ + bindable::Column, domain::Domain, statement::Statement, + thread_safe_connection::ThreadSafeConnection, + }, + sqlez_macros::sql, +}; + +/// Lightweight metadata for any thread (native or ACP), enough to populate +/// the sidebar list and route to the correct load path when clicked. +#[derive(Debug, Clone)] +pub struct SidebarThreadRow { + pub session_id: acp::SessionId, + /// `None` for native Zed threads, `Some("claude-code")` etc. for ACP agents. + pub agent_name: Option, + pub title: SharedString, + pub updated_at: DateTime, + pub created_at: Option>, + pub folder_paths: PathList, +} + +pub struct SidebarDb(ThreadSafeConnection); + +impl Domain for SidebarDb { + const NAME: &str = stringify!(SidebarDb); + + const MIGRATIONS: &[&str] = &[sql!( + CREATE TABLE IF NOT EXISTS sidebar_threads( + session_id TEXT PRIMARY KEY, + agent_name TEXT, + title TEXT NOT NULL, + updated_at TEXT NOT NULL, + created_at TEXT, + folder_paths TEXT, + folder_paths_order TEXT + ) STRICT; + )]; +} + +db::static_connection!(SIDEBAR_DB, SidebarDb, []); +``` + +### CRUD Methods + +```rust +impl SidebarDb { + /// Upsert metadata for a thread (native or ACP). + pub async fn save(&self, row: &SidebarThreadRow) -> Result<()> { + let id = row.session_id.0.clone(); + let agent_name = row.agent_name.clone(); + let title = row.title.to_string(); + let updated_at = row.updated_at.to_rfc3339(); + let created_at = row.created_at.map(|dt| dt.to_rfc3339()); + let serialized = row.folder_paths.serialize(); + let (fp, fpo) = if row.folder_paths.is_empty() { + (None, None) + } else { + (Some(serialized.paths), Some(serialized.order)) + }; + + self.write(move |conn| { + let mut stmt = Statement::prepare( + conn, + "INSERT INTO sidebar_threads(session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(session_id) DO UPDATE SET + agent_name = excluded.agent_name, + title = excluded.title, + updated_at = excluded.updated_at, + folder_paths = excluded.folder_paths, + folder_paths_order = excluded.folder_paths_order", + )?; + let mut i = stmt.bind(&id, 1)?; + i = stmt.bind(&agent_name, i)?; + i = stmt.bind(&title, i)?; + i = stmt.bind(&updated_at, i)?; + i = stmt.bind(&created_at, i)?; + i = stmt.bind(&fp, i)?; + stmt.bind(&fpo, i)?; + stmt.exec() + }) + .await + } + + /// List all sidebar thread metadata, ordered by updated_at descending. + pub fn list(&self) -> Result> { + self.select::( + "SELECT session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order + FROM sidebar_threads + ORDER BY updated_at DESC" + )?(()) + } + + /// List threads for a specific folder path set. + pub fn list_for_paths(&self, paths: &PathList) -> Result> { + let serialized = paths.serialize(); + self.select_bound::(sql!( + SELECT session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order + FROM sidebar_threads + WHERE folder_paths = ? + ORDER BY updated_at DESC + ))?(serialized.paths) + } + + /// Look up a single thread by session ID. + pub fn get(&self, session_id: &acp::SessionId) -> Result> { + let id = session_id.0.clone(); + self.select_row_bound::, SidebarThreadRow>(sql!( + SELECT session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order + FROM sidebar_threads + WHERE session_id = ? + ))?(id) + } + + /// Return the total number of rows in the table. + pub fn count(&self) -> Result { + let count: (i32, i32) = self.select_row(sql!( + SELECT COUNT(*) FROM sidebar_threads + ))?(())?.unwrap_or_default(); + Ok(count.0 as usize) + } + + /// Delete metadata for a single thread. + pub async fn delete(&self, session_id: acp::SessionId) -> Result<()> { + let id = session_id.0; + self.write(move |conn| { + let mut stmt = Statement::prepare( + conn, + "DELETE FROM sidebar_threads WHERE session_id = ?", + )?; + stmt.bind(&id, 1)?; + stmt.exec() + }) + .await + } + + /// Delete all thread metadata. + pub async fn delete_all(&self) -> Result<()> { + self.write(move |conn| { + let mut stmt = Statement::prepare( + conn, + "DELETE FROM sidebar_threads", + )?; + stmt.exec() + }) + .await + } +} +``` + +### `Column` Implementation + +```rust +impl Column for SidebarThreadRow { + fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { + let (id, next): (Arc, i32) = Column::column(statement, start_index)?; + let (agent_name, next): (Option, i32) = Column::column(statement, next)?; + let (title, next): (String, i32) = Column::column(statement, next)?; + let (updated_at_str, next): (String, i32) = Column::column(statement, next)?; + let (created_at_str, next): (Option, i32) = Column::column(statement, next)?; + let (folder_paths_str, next): (Option, i32) = Column::column(statement, next)?; + let (folder_paths_order_str, next): (Option, i32) = Column::column(statement, next)?; + + let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc); + let created_at = created_at_str + .as_deref() + .map(DateTime::parse_from_rfc3339) + .transpose()? + .map(|dt| dt.with_timezone(&Utc)); + + let folder_paths = folder_paths_str + .map(|paths| { + PathList::deserialize(&util::path_list::SerializedPathList { + paths, + order: folder_paths_order_str.unwrap_or_default(), + }) + }) + .unwrap_or_default(); + + Ok(( + SidebarThreadRow { + session_id: acp::SessionId::new(id), + agent_name, + title: title.into(), + updated_at, + created_at, + folder_paths, + }, + next, + )) + } +} +``` + +**Key points:** + +- `SIDEBAR_DB` is a `LazyLock` static — initialized on first use, no manual connection management. +- The `agent_name` column is `NULL` for native Zed threads and a string like `"claude-code"` for ACP agents. This replaces the `agent_type` field from the original plan. +- The DB file lives alongside other `static_connection!` databases. +- `ThreadsDatabase` and `ThreadStore` are **completely unchanged** by this step. + +--- + +## Step 2: Replace `ThreadStore` Reads in `rebuild_contents` with `SidebarDb` Reads + +**File:** `crates/agent_ui/src/sidebar.rs` + +### Remove `ThreadStore` Dependency + +1. **Remove** `ThreadStore::global(cx)` and `ThreadStore::try_global(cx)` from `Sidebar::new` and `rebuild_contents`. +2. **Remove** the `cx.observe_in(&thread_store, ...)` subscription that triggers `update_entries` when `ThreadStore` changes. +3. **Replace** `thread_store.read(cx).threads_for_paths(&path_list)` calls with `SIDEBAR_DB.list_for_paths(&path_list)` (or read all rows once at the top of `rebuild_contents` and index them in memory, which is simpler and avoids repeated DB calls). + +### New Data Flow in `rebuild_contents` + +```rust +fn rebuild_contents(&mut self, cx: &App) { + // ... existing workspace iteration setup ... + + // Read ALL sidebar thread metadata once, index by folder_paths. + let all_sidebar_threads = SIDEBAR_DB.list().unwrap_or_default(); + let mut threads_by_paths: HashMap> = HashMap::new(); + for row in all_sidebar_threads { + threads_by_paths + .entry(row.folder_paths.clone()) + .or_default() + .push(row); + } + + for (ws_index, workspace) in workspaces.iter().enumerate() { + // ... existing absorbed-workspace logic ... + + let path_list = workspace_path_list(workspace, cx); + + if should_load_threads { + let mut seen_session_ids: HashSet = HashSet::new(); + + // Read from SidebarDb instead of ThreadStore + if let Some(rows) = threads_by_paths.get(&path_list) { + for row in rows { + seen_session_ids.insert(row.session_id.clone()); + let (agent, icon) = match &row.agent_name { + None => (Agent::NativeAgent, IconName::ZedAgent), + Some(name) => ( + Agent::Custom { name: name.clone().into() }, + IconName::ZedAgent, // placeholder, resolved in Step 5 + ), + }; + threads.push(ThreadEntry { + agent, + session_info: AgentSessionInfo { + session_id: row.session_id.clone(), + cwd: None, + title: Some(row.title.clone()), + updated_at: Some(row.updated_at), + created_at: row.created_at, + meta: None, + }, + icon, + icon_from_external_svg: None, + status: AgentThreadStatus::default(), + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: false, + is_background: false, + highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), + }); + } + } + + // ... existing linked git worktree logic, also reading from threads_by_paths ... + // ... existing live thread overlay logic (unchanged) ... + } + } +} +``` + +### What Changes + +- `rebuild_contents` reads from `SIDEBAR_DB` instead of `ThreadStore`. +- The `ThreadEntry.agent` field now carries `Agent::Custom { name }` for ACP threads, enabling correct routing in `activate_thread`. +- The live thread overlay logic (from `all_thread_infos_for_workspace`) is **unchanged** — it still reads from `AgentPanel` to get real-time status of running threads. + +### What Stays the Same + +- The entire workspace/absorbed-workspace/git-worktree structure. +- The live thread overlay pass. +- The notification tracking logic. +- The search/filter logic. + +--- + +## Step 3: Write Native Thread Metadata to `SidebarDb` + +**File:** `crates/agent_ui/src/sidebar.rs` and/or `crates/agent_ui/src/agent_panel.rs` + +When a native thread is saved (after conversation, on title update, etc.), we also write its metadata to `SidebarDb`. There are two approaches: + +### Option A: Subscribe to `ThreadStore` Changes (Recommended) + +Keep a one-directional sync: when `ThreadStore` finishes a `save_thread` or `reload`, the sidebar syncs the metadata to `SidebarDb`. This can be done in the sidebar's workspace subscription or by observing `ThreadStore` changes purely for the purpose of syncing (not for reading). + +```rust +// In Sidebar::subscribe_to_workspace or a dedicated sync method: +fn sync_native_threads_to_sidebar_db(&self, cx: &App) { + if let Some(thread_store) = ThreadStore::try_global(cx) { + let entries: Vec<_> = thread_store.read(cx).entries().collect(); + cx.background_spawn(async move { + for meta in entries { + SIDEBAR_DB.save(&SidebarThreadRow { + session_id: meta.id, + agent_name: None, // native + title: meta.title, + updated_at: meta.updated_at, + created_at: meta.created_at, + folder_paths: meta.folder_paths, + }).await.log_err(); + } + }).detach(); + } +} +``` + +### Option B: Write at the Point of Save + +In `AgentPanel` or wherever `thread_store.save_thread()` is called, also call `SIDEBAR_DB.save(...)`. This is more direct but requires touching more call sites. + +**Recommendation:** Option A is simpler for the initial implementation. We observe `ThreadStore` changes, diff against `SidebarDb`, and sync. Later, if we want to remove `ThreadStore` entirely from the write path for native threads, we can switch to Option B. + +--- + +## Step 4: Write ACP Thread Metadata to `SidebarDb` + +**File:** `crates/agent_ui/src/connection_view.rs` (or `agent_panel.rs`) + +When ACP sessions are created, updated, or listed, write metadata directly to `SidebarDb`: + +- **On new session creation:** After `connection.new_session()` returns the `AcpThread`, call `SIDEBAR_DB.save(...)`. +- **On title update:** ACP threads receive title updates via `SessionInfoUpdate`. When these come in, call `SIDEBAR_DB.save(...)` with the new title and updated timestamp. +- **On session list refresh:** When `AgentSessionList::list_sessions` returns for an ACP agent, bulk-sync the metadata into `SidebarDb`. + +After any write, call `cx.notify()` on the `Sidebar` entity (or use a channel/event) to trigger a `rebuild_contents`. + +### Triggering Sidebar Refresh + +Since the sidebar no longer observes `ThreadStore`, we need a mechanism to trigger `rebuild_contents` after DB writes. Options: + +1. **Emit an event from `AgentPanel`** — The sidebar already subscribes to `AgentPanelEvent`. Add a new variant like `AgentPanelEvent::ThreadMetadataChanged` and emit it after saving to `SidebarDb`. +2. **Use `cx.notify()` directly** — If the save happens within a `Sidebar` method, just call `self.update_entries(cx)`. +3. **Observe a lightweight signal entity** — A simple `Entity<()>` that gets notified after DB writes. + +**Recommendation:** Option 1 (emit from `AgentPanel`) is cleanest since the sidebar already subscribes to panel events. + +--- + +## Step 5: Handle Agent Icon Resolution for ACP Threads + +**File:** `crates/agent_ui/src/sidebar.rs` + +For ACP threads in the sidebar, we need the correct agent icon. The `agent_name` string stored in `SidebarDb` maps to an agent in the `AgentServerStore`, which has icon info. + +In `rebuild_contents`, after building the initial thread list from `SidebarDb`, resolve icons for ACP threads: + +```rust +// For ACP threads, look up the icon from the agent server store +if let Some(name) = &row.agent_name { + if let Some(agent_server_store) = /* get from workspace */ { + // resolve icon from agent_server_store using name + } +} +``` + +--- + +## Step 6: Handle Delete Operations Correctly + +**File:** `crates/agent_ui/src/sidebar.rs` + +When the user deletes a thread from the sidebar: + +- **All threads** → Delete from `SidebarDb` via `SIDEBAR_DB.delete(session_id)`. +- **Native threads** → _Also_ delete from `ThreadStore`/`ThreadsDatabase` (to clean up the blob data). +- **ACP threads** → Optionally notify the ACP server via `AgentSessionList::delete_session`. + +The `agent_name` field on `SidebarThreadRow` (or the `Agent` enum on `ThreadEntry`) tells us which path to take. + +When the user clears all history: + +```rust +// Delete all sidebar metadata +SIDEBAR_DB.delete_all().await?; +// Also clear native thread blobs +thread_store.delete_threads(cx); +// Optionally notify ACP servers +``` + +--- + +## Step 7: Handle `activate_thread` Routing + +**File:** `crates/agent_ui/src/sidebar.rs`, `crates/agent_ui/src/agent_panel.rs` + +In `activate_thread`, branch on the `Agent` variant: + +- `Agent::NativeAgent` → Call `panel.load_agent_thread(Agent::NativeAgent, session_id, ...)` (current behavior). +- `Agent::Custom { name }` → Call `panel.load_agent_thread(Agent::Custom { name }, session_id, ...)` so it routes to the correct `AgentConnection::load_session`. + +This is already partially set up — `activate_thread` takes an `Agent` parameter. The key change is that `ThreadEntry` now carries the correct `Agent` variant based on `SidebarThreadRow.agent_name`. + +--- + +## Step 8: Handle `activate_archived_thread` Without ThreadStore + +**File:** `crates/agent_ui/src/sidebar.rs` + +Currently, `activate_archived_thread` looks up `saved_path_list` from `ThreadStore`: + +```rust +let saved_path_list = ThreadStore::try_global(cx).and_then(|thread_store| { + thread_store + .read(cx) + .thread_from_session_id(&session_info.session_id) + .map(|thread| thread.folder_paths.clone()) +}); +``` + +Replace this with a targeted `SidebarDb::get` lookup (single-row SELECT, no full table scan): + +```rust +let saved_path_list = SIDEBAR_DB + .get(&session_info.session_id) + .ok() + .flatten() + .map(|row| row.folder_paths); +``` + +--- + +## Step 9: Error Handling for Offline Agents + +When an ACP thread is clicked but the agent server is not running: + +- Show a toast/notification explaining the agent is offline. +- Keep the metadata in the sidebar (don't remove it). +- Optionally offer to start the agent server. + +--- + +## Step 10: Migration — Backfill Existing Native Threads + +On first launch after this change, the `SidebarDb` will be empty while `ThreadsDatabase` has existing native threads. We need a one-time backfill: + +```rust +// In Sidebar::new or a dedicated init method: +fn backfill_native_threads_if_needed(cx: &App) { + if SIDEBAR_DB.count() > 0 { + return; // Already populated + } + + if let Some(thread_store) = ThreadStore::try_global(cx) { + let entries: Vec<_> = thread_store.read(cx).entries().collect(); + cx.background_spawn(async move { + for meta in entries { + SIDEBAR_DB.save(&SidebarThreadRow { + session_id: meta.id, + agent_name: None, + title: meta.title, + updated_at: meta.updated_at, + created_at: meta.created_at, + folder_paths: meta.folder_paths, + }).await.log_err(); + } + }).detach(); + } +} +``` + +--- + +## Summary of Files to Change + +| File | Changes | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/agent_ui/Cargo.toml` | Add `db.workspace = true`, `sqlez.workspace = true`, `sqlez_macros.workspace = true`, `chrono.workspace = true` dependencies | +| `crates/agent_ui/src/sidebar.rs` | **Main changes.** Add `SidebarDb` domain + `SIDEBAR_DB` static + `SidebarThreadRow`. Replace all `ThreadStore` reads in `rebuild_contents` with `SidebarDb` reads. Update `activate_archived_thread`. Add native thread sync logic. Add backfill on first run. | +| `crates/agent_ui/src/agent_panel.rs` | Emit `AgentPanelEvent::ThreadMetadataChanged` after thread saves. Potentially write ACP metadata to `SidebarDb` here. | +| `crates/agent_ui/src/connection_view.rs` | Write ACP metadata to `SidebarDb` on session creation, title updates, and session list refreshes. | + +## What Is NOT Changed + +| File / Area | Why | +| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| `threads` table schema | No migration needed — native blob persistence is completely untouched | +| `ThreadsDatabase` methods | `save_thread_sync`, `load_thread`, `list_threads`, `delete_thread`, `delete_threads` — all unchanged | +| `ThreadStore` struct/methods | Stays exactly as-is. It's still used for native thread blob save/load. The sidebar just no longer reads from it for display. | +| `NativeAgent::load_thread` / `open_thread` | These deserialize `DbThread` blobs — completely unaffected | +| `crates/acp_thread/` | No new persistence module needed there (unlike the original plan) | +| `crates/agent/src/db.rs` | `DbThreadMetadata` is unchanged — no `agent_type` field added | + +## Execution Order + +1. **SidebarDb domain** (Step 1) — Create `SidebarDb`, `SidebarThreadRow`, `SIDEBAR_DB` static, CRUD methods in `sidebar.rs`. +2. **Replace reads** (Step 2) — Swap `ThreadStore` reads in `rebuild_contents` for `SidebarDb` reads. +3. **Native write path** (Step 3) — Sync native thread metadata from `ThreadStore` into `SidebarDb`. +4. **ACP write path** (Step 4) — Write ACP thread metadata to `SidebarDb` from connection views. +5. **Icon resolution** (Step 5) — Resolve ACP agent icons in the sidebar. +6. **Delete path** (Step 6) — Route deletes to `SidebarDb` + native blob cleanup + ACP server notification. +7. **Activate routing** (Step 7) — Ensure `activate_thread` routes correctly based on `Agent` variant. +8. **Archive fix** (Step 8) — Update `activate_archived_thread` to use `SidebarDb`. +9. **Migration** (Step 10) — Backfill existing native threads on first run. +10. **Polish** (Step 9) — Error handling for offline agents. + +## Key Differences from Original Plan + +| Aspect | Original Plan | Revised Plan | +| ------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | +| **Where ACP metadata lives** | New `AcpThreadMetadataDb` in `crates/acp_thread/` | `SidebarDb` in `crates/agent_ui/src/sidebar.rs` | +| **Where sidebar reads from** | `ThreadStore` (which merges native + ACP) | `SidebarDb` directly (single source) | +| **ThreadStore changes** | Added `agent_type` to `DbThreadMetadata`, merge logic in `reload`, new save/delete methods | **None** — ThreadStore is untouched | +| **`crates/agent/src/db.rs` changes** | Added `agent_type: Option` to `DbThreadMetadata` | **None** | +| **Merge complexity** | Two data sources merged in `ThreadStore::reload` | No merge — one table, one read | +| **Crate dependencies** | `acp_thread` gains `db` dependency | `agent_ui` gains `db` dependency (more natural — it's a UI persistence concern) |