Store ACP thread metadata (#51657)

Bennet Bo Fenner , cameron , and Ben Brandt created

Before you mark this PR as ready for review, make sure that you have:
- [x] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [x] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- N/A

---------

Co-authored-by: cameron <cameron.studdstreet@gmail.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>

Change summary

crates/acp_thread/src/acp_thread.rs                       | 119 +
crates/acp_thread/src/connection.rs                       |  32 
crates/acp_tools/src/acp_tools.rs                         |  14 
crates/agent/src/agent.rs                                 |  64 
crates/agent/src/db.rs                                    |   7 
crates/agent/src/native_agent_server.rs                   |   7 
crates/agent/src/tests/mod.rs                             |  16 
crates/agent/src/thread_store.rs                          |  64 
crates/agent_servers/src/acp.rs                           |  62 
crates/agent_servers/src/agent_servers.rs                 |   6 
crates/agent_servers/src/custom.rs                        | 116 
crates/agent_servers/src/e2e_tests.rs                     |   9 
crates/agent_ui/src/agent_configuration.rs                |   4 
crates/agent_ui/src/agent_connection_store.rs             |   5 
crates/agent_ui/src/agent_diff.rs                         |  18 
crates/agent_ui/src/agent_panel.rs                        | 136 +-
crates/agent_ui/src/agent_ui.rs                           |  35 
crates/agent_ui/src/connection_view.rs                    | 354 +---
crates/agent_ui/src/connection_view/thread_view.rs        |  32 
crates/agent_ui/src/entry_view_state.rs                   |  22 
crates/agent_ui/src/message_editor.rs                     |  15 
crates/agent_ui/src/sidebar.rs                            | 574 ++++----
crates/agent_ui/src/test_support.rs                       |   5 
crates/agent_ui/src/thread_history.rs                     |  14 
crates/agent_ui/src/thread_history_view.rs                |   2 
crates/agent_ui/src/thread_metadata_store.rs              | 528 ++++++++
crates/agent_ui/src/threads_archive_view.rs               |  22 
crates/agent_ui/src/ui/acp_onboarding_modal.rs            |   4 
crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs   |   4 
crates/eval_cli/src/main.rs                               |   7 
crates/project/src/agent_registry_store.rs                |  12 
crates/project/src/agent_server_store.rs                  |  76 
crates/project/src/project.rs                             |   4 
crates/project/tests/integration/ext_agent_tests.rs       |   8 
crates/project/tests/integration/extension_agent_tests.rs |  18 
crates/util/src/path_list.rs                              |  17 
crates/zed/src/visual_test_runner.rs                      |   7 
docs/acp-threads-in-sidebar-plan.md                       | 580 +++++++++
38 files changed, 2,058 insertions(+), 961 deletions(-)

Detailed changes

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<PathBuf>,
+    work_dirs: Option<PathList>,
     parent_session_id: Option<acp::SessionId>,
     title: SharedString,
     provisional_title: Option<SharedString>,
@@ -1119,7 +1120,7 @@ impl AcpThread {
     pub fn new(
         parent_session_id: Option<acp::SessionId>,
         title: impl Into<SharedString>,
-        cwd: Option<PathBuf>,
+        work_dirs: Option<PathList>,
         connection: Rc<dyn AgentConnection>,
         project: Entity<Project>,
         action_log: Entity<ActionLog>,
@@ -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<Self>,
             project: Entity<Project>,
-            cwd: &Path,
+            work_dirs: PathList,
             cx: &mut App,
         ) -> Task<gpui::Result<Entity<AcpThread>>> {
             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();
 

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<Self>,
         project: Entity<Project>,
-        cwd: &Path,
+        _work_dirs: PathList,
         cx: &mut App,
     ) -> Task<Result<Entity<AcpThread>>>;
 
@@ -47,7 +43,7 @@ pub trait AgentConnection {
         self: Rc<Self>,
         _session_id: acp::SessionId,
         _project: Entity<Project>,
-        _cwd: &Path,
+        _work_dirs: PathList,
         _title: Option<SharedString>,
         _cx: &mut App,
     ) -> Task<Result<Entity<AcpThread>>> {
@@ -78,7 +74,7 @@ pub trait AgentConnection {
         self: Rc<Self>,
         _session_id: acp::SessionId,
         _project: Entity<Project>,
-        _cwd: &Path,
+        _work_dirs: PathList,
         _title: Option<SharedString>,
         _cx: &mut App,
     ) -> Task<Result<Entity<AcpThread>>> {
@@ -243,7 +239,7 @@ impl AgentSessionListResponse {
 #[derive(Debug, Clone, PartialEq)]
 pub struct AgentSessionInfo {
     pub session_id: acp::SessionId,
-    pub cwd: Option<PathBuf>,
+    pub work_dirs: Option<PathList>,
     pub title: Option<SharedString>,
     pub updated_at: Option<DateTime<Utc>>,
     pub created_at: Option<DateTime<Utc>>,
@@ -254,7 +250,7 @@ impl AgentSessionInfo {
     pub fn new(session_id: impl Into<acp::SessionId>) -> 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<Self>,
             project: Entity<Project>,
-            cwd: &Path,
+            work_dirs: PathList,
             cx: &mut gpui::App,
         ) -> Task<gpui::Result<Entity<AcpThread>>> {
             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,

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<acp::ClientSideConnection>,
 }
 
@@ -65,12 +65,12 @@ impl AcpConnectionRegistry {
 
     pub fn set_active_connection(
         &self,
-        server_name: impl Into<SharedString>,
+        agent_id: AgentId,
         connection: &Rc<acp::ClientSideConnection>,
         cx: &mut Context<Self>,
     ) {
         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<WatchedConnectionMessage>,
     list_state: ListState,
     connection: Weak<acp::ClientSideConnection>,
@@ -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()
     }

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<AgentId> = 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<Self>,
         project: Entity<Project>,
-        cwd: &Path,
+        work_dirs: PathList,
         cx: &mut App,
     ) -> Task<Result<Entity<acp_thread::AcpThread>>> {
-        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<Self>,
         session_id: acp::SessionId,
         project: Entity<Project>,
-        _cwd: &Path,
+        _work_dirs: PathList,
         _title: Option<SharedString>,
         cx: &mut App,
     ) -> Task<Result<Entity<acp_thread::AcpThread>>> {
@@ -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();

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<acp::SessionId>,
-    #[serde(alias = "summary")]
     pub title: SharedString,
     pub updated_at: DateTime<Utc>,
     pub created_at: Option<DateTime<Utc>>,
@@ -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]

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 {

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();

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<ThreadStore>);
@@ -11,7 +10,6 @@ impl Global for GlobalThreadStore {}
 
 pub struct ThreadStore {
     threads: Vec<DbThreadMetadata>,
-    threads_by_paths: HashMap<PathList, Vec<usize>>,
 }
 
 impl ThreadStore {
@@ -31,7 +29,6 @@ impl ThreadStore {
     pub fn new(cx: &mut Context<Self>) -> 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<Item = DbThreadMetadata> + '_ {
         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<Item = &DbThreadMetadata> {
-        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());
-        });
-    }
 }

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<acp::ClientSideConnection>,
@@ -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<acp::SessionModeId>,
@@ -167,7 +168,7 @@ pub async fn connect(
     cx: &mut AsyncApp,
 ) -> Result<Rc<dyn AgentConnection>> {
     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<acp::SessionModeId>,
@@ -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<RefCell<Vec<acp::SessionConfigOption>>>,
         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<Self>,
         project: Entity<Project>,
-        cwd: &Path,
+        work_dirs: PathList,
         cx: &mut App,
     ) -> Task<Result<Entity<AcpThread>>> {
-        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<Self>,
         session_id: acp::SessionId,
         project: Entity<Project>,
-        cwd: &Path,
+        work_dirs: PathList,
         title: Option<SharedString>,
         cx: &mut App,
     ) -> Task<Result<Entity<AcpThread>>> {
@@ -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<Self>,
         session_id: acp::SessionId,
         project: Entity<Project>,
-        cwd: &Path,
+        work_dirs: PathList,
         title: Option<SharedString>,
         cx: &mut App,
     ) -> Task<Result<Entity<AcpThread>>> {
@@ -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,

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,

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::<AllAgentServersSettings>(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::<AllAgentServersSettings>(None)
-                .get(self.name().as_ref())
+                .get(self.agent_id().0.as_ref())
                 .cloned()
         });
 
@@ -80,7 +80,7 @@ impl AgentServer for CustomAgentServer {
         fs: Arc<dyn Fs>,
         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<acp::SessionModeId>, fs: Arc<dyn Fs>, 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::<AllAgentServersSettings>(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<acp::ModelId>, fs: Arc<dyn Fs>, 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::<AllAgentServersSettings>(None)
-                .get(self.name().as_ref())
+                .get(self.agent_id().as_ref())
                 .cloned()
         });
 
@@ -200,13 +200,13 @@ impl AgentServer for CustomAgentServer {
         fs: Arc<dyn Fs>,
         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::<AllAgentServersSettings>(None)
-                .get(self.name().as_ref())
+                .get(self.agent_id().as_ref())
                 .cloned()
         });
 
@@ -251,15 +251,15 @@ impl AgentServer for CustomAgentServer {
         fs: Arc<dyn Fs>,
         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<Result<Rc<dyn AgentConnection>>> {
-        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::<AllAgentServersSettings>(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<Result<String>> {
     })
 }
 
-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<AgentId>, 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::<AllAgentServersSettings>(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<AgentId>,
+    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 { .. }
             ));
         });

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<T, F>(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(

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(

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();
     }

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();

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<SerializedAgentPanel> {
         .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
 }
 
-#[derive(Serialize, Deserialize, Debug, Clone)]
+#[derive(Serialize, Deserialize, Debug)]
 struct SerializedAgentPanel {
     width: Option<Pixels>,
     selected_agent: Option<AgentType>,
@@ -190,12 +190,12 @@ struct SerializedAgentPanel {
     start_thread_in: Option<StartThreadIn>,
 }
 
-#[derive(Serialize, Deserialize, Debug, Clone)]
+#[derive(Serialize, Deserialize, Debug)]
 struct SerializedActiveThread {
     session_id: String,
     agent_type: AgentType,
     title: Option<String>,
-    cwd: Option<std::path::PathBuf>,
+    work_dirs: Option<SerializedPathList>,
 }
 
 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<Agent> 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<PathBuf>,
+        work_dirs: Option<PathList>,
         title: Option<SharedString>,
         window: &mut Window,
         cx: &mut Context<Self>,
@@ -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<crate::Agent>,
         resume_session_id: Option<acp::SessionId>,
-        cwd: Option<PathBuf>,
+        work_dirs: Option<PathList>,
         title: Option<SharedString>,
         initial_content: Option<AgentInitialContent>,
         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<Agent> {
         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<PathBuf>,
+        work_dirs: Option<PathList>,
         title: Option<SharedString>,
         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<dyn AgentServer>,
         resume_session_id: Option<acp::SessionId>,
-        cwd: Option<PathBuf>,
+        work_dirs: Option<PathList>,
         title: Option<SharedString>,
         initial_content: Option<AgentInitialContent>,
         workspace: WeakEntity<Workspace>,
@@ -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::<Vec<_>>();
@@ -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::<AgentType>(r#""ClaudeAgent""#).unwrap(),
             AgentType::Custom {
-                name: CLAUDE_AGENT_NAME.into(),
+                id: CLAUDE_AGENT_ID.into(),
             },
         );
         assert_eq!(
             serde_json::from_str::<AgentType>(r#""ClaudeCode""#).unwrap(),
             AgentType::Custom {
-                name: CLAUDE_AGENT_NAME.into(),
+                id: CLAUDE_AGENT_ID.into(),
             },
         );
         assert_eq!(
             serde_json::from_str::<AgentType>(r#""Codex""#).unwrap(),
             AgentType::Custom {
-                name: CODEX_NAME.into(),
+                id: CODEX_ID.into(),
             },
         );
         assert_eq!(
             serde_json::from_str::<AgentType>(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::<AgentType>(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(),
             },
         );
     }

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<dyn agent_servers::AgentServer> {
         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::<Agent>(r#""claude_code""#).unwrap(),
             Agent::Custom {
-                name: CLAUDE_AGENT_NAME.into(),
+                id: CLAUDE_AGENT_ID.into(),
             },
         );
         assert_eq!(
             serde_json::from_str::<Agent>(r#""codex""#).unwrap(),
             Agent::Custom {
-                name: CODEX_NAME.into(),
+                id: CODEX_ID.into(),
             },
         );
         assert_eq!(
             serde_json::from_str::<Agent>(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::<Agent>(r#"{"custom":{"name":"my-agent"}}"#).unwrap(),
             Agent::Custom {
-                name: "my-agent".into(),
+                id: "my-agent".into(),
             },
         );
     }

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<AgentConnectionStore>,
         connection_key: Agent,
         resume_session_id: Option<acp::SessionId>,
-        cwd: Option<PathBuf>,
+        work_dirs: Option<PathList>,
         title: Option<SharedString>,
         initial_content: Option<AgentInitialContent>,
         workspace: WeakEntity<Workspace>,
@@ -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<AgentConnectionStore>,
         connection_key: Agent,
         resume_session_id: Option<acp::SessionId>,
-        cwd: Option<PathBuf>,
+        work_dirs: Option<PathList>,
         title: Option<SharedString>,
         project: Entity<Project>,
         initial_content: Option<AgentInitialContent>,
@@ -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<Self>,
     ) -> Entity<ThreadView> {
-        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<Self>,
         err: AuthRequired,
-        agent_name: SharedString,
+        agent_id: AgentId,
         connection: Rc<dyn AgentConnection>,
         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<Self>,
     ) -> 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<Self>) {
-        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<Self>,
             project: Entity<Project>,
-            _cwd: &Path,
+            _work_dirs: PathList,
             cx: &mut App,
         ) -> Task<anyhow::Result<Entity<AcpThread>>> {
             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<Self>,
             project: Entity<Project>,
-            _cwd: &Path,
+            _work_dirs: PathList,
             cx: &mut gpui::App,
         ) -> Task<gpui::Result<Entity<AcpThread>>> {
             let thread = build_test_thread(
@@ -4048,7 +3925,7 @@ pub(crate) mod tests {
             self: Rc<Self>,
             session_id: acp::SessionId,
             project: Entity<Project>,
-            _cwd: &Path,
+            _work_dirs: PathList,
             _title: Option<SharedString>,
             cx: &mut App,
         ) -> Task<gpui::Result<Entity<AcpThread>>> {
@@ -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<Self>,
             project: Entity<Project>,
-            cwd: &Path,
+            work_dirs: PathList,
             cx: &mut gpui::App,
         ) -> Task<gpui::Result<Entity<AcpThread>>> {
             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<Self>,
             project: Entity<Project>,
-            cwd: &Path,
+            work_dirs: PathList,
             cx: &mut gpui::App,
         ) -> Task<gpui::Result<Entity<AcpThread>>> {
             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<Self>,
             project: Entity<Project>,
-            cwd: &Path,
+            work_dirs: PathList,
             cx: &mut gpui::App,
         ) -> Task<gpui::Result<Entity<AcpThread>>> {
             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<Mutex<Option<PathBuf>>>,
+        captured_work_dirs: Arc<Mutex<Option<PathList>>>,
     }
 
     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<Self>,
             project: Entity<Project>,
-            cwd: &Path,
+            work_dirs: PathList,
             cx: &mut gpui::App,
         ) -> Task<gpui::Result<Entity<AcpThread>>> {
-            *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<Self>,
             session_id: acp::SessionId,
             project: Entity<Project>,
-            cwd: &Path,
+            work_dirs: PathList,
             _title: Option<SharedString>,
             cx: &mut App,
         ) -> Task<gpui::Result<Entity<AcpThread>>> {
-            *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<Self>,
             project: Entity<Project>,
-            cwd: &Path,
+            work_dirs: PathList,
             cx: &mut gpui::App,
         ) -> Task<gpui::Result<Entity<AcpThread>>> {
             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,

crates/agent_ui/src/connection_view/thread_view.rs 🔗

@@ -170,7 +170,7 @@ pub struct ThreadView {
     pub server_view: WeakEntity<ConnectionView>,
     pub agent_icon: IconName,
     pub agent_icon_from_external_svg: Option<SharedString>,
-    pub agent_name: SharedString,
+    pub agent_id: AgentId,
     pub focus_handle: FocusHandle,
     pub workspace: WeakEntity<Workspace>,
     pub entry_view_state: Entity<EntryViewState>,
@@ -259,7 +259,7 @@ impl ThreadView {
         server_view: WeakEntity<ConnectionView>,
         agent_icon: IconName,
         agent_icon_from_external_svg: Option<SharedString>,
-        agent_name: SharedString,
+        agent_id: AgentId,
         agent_display_name: SharedString,
         workspace: WeakEntity<Workspace>,
         entry_view_state: Entity<EntryViewState>,
@@ -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()
         }
     }
 

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<Entry>,
     prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
     available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
-    agent_name: SharedString,
+    agent_id: AgentId,
 }
 
 impl EntryViewState {
@@ -43,7 +43,7 @@ impl EntryViewState {
         prompt_store: Option<Entity<PromptStore>>,
         prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
         available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
-        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();

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<Workspace>,
     prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
     available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
-    agent_name: SharedString,
+    agent_id: AgentId,
     thread_store: Option<Entity<ThreadStore>>,
     _subscriptions: Vec<Subscription>,
     _parse_slash_command_task: Task<()>,
@@ -113,7 +114,7 @@ impl MessageEditor {
         prompt_store: Option<Entity<PromptStore>>,
         prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
         available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
-        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<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
         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
         })
     }

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<Entity<ThreadsArchiveView>>,
     _subscriptions: Vec<gpui::Subscription>,
+    _update_entries_task: Option<gpui::Task<()>>,
 }
 
 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::<AgentV2FeatureFlag, _>(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<ThreadMetadata>, 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<PathList, Vec<ThreadMetadata>> = 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<acp::SessionId> = 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<Path>)> =
                         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<Self>) {
+    fn update_entries(&mut self, select_first_thread: bool, cx: &mut Context<Self>) {
         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<Self>) {}
 
     fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
         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<Self>,
     ) {
-        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<Utc>) -> 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<Utc>,
+        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<Sidebar>, 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.

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()
     }
 

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,

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,

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::<AgentV2FeatureFlag>() {
+        migrate_thread_metadata(cx);
+    }
+    cx.observe_flag::<AgentV2FeatureFlag, _>(|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::<Vec<_>>();
+                    for entry in metadata {
+                        this.save(entry, cx).detach_and_log_err(cx);
+                    }
+                })
+                .ok();
+            }
+        })
+        .detach();
+    });
+}
+
+struct GlobalThreadMetadataStore(Entity<ThreadMetadataStore>);
+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<AgentId>,
+    pub title: SharedString,
+    pub updated_at: DateTime<Utc>,
+    pub created_at: Option<DateTime<Utc>>,
+    pub folder_paths: PathList,
+}
+
+pub struct ThreadMetadataStore {
+    db: ThreadMetadataDb,
+    session_subscriptions: HashMap<acp::SessionId, Subscription>,
+}
+
+impl ThreadMetadataStore {
+    #[cfg(not(any(test, feature = "test-support")))]
+    pub fn init_global(cx: &mut App) {
+        if cx.has_global::<Self>() {
+            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::<ThreadMetadataDb>(&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<Entity<Self>> {
+        cx.try_global::<GlobalThreadMetadataStore>()
+            .map(|store| store.0.clone())
+    }
+
+    pub fn global(cx: &App) -> Entity<Self> {
+        cx.global::<GlobalThreadMetadataStore>().0.clone()
+    }
+
+    pub fn list(&self, cx: &App) -> Task<Result<Vec<ThreadMetadata>>> {
+        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<Self>) -> Task<Result<()>> {
+        if !cx.has_flag::<AgentV2FeatureFlag>() {
+            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<Self>,
+    ) -> Task<Result<()>> {
+        if !cx.has_flag::<AgentV2FeatureFlag>() {
+            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>) -> Self {
+        let weak_store = cx.weak_entity();
+
+        cx.observe_new::<acp_thread::AcpThread>(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<acp_thread::AcpThread>,
+        event: &acp_thread::AcpThreadEvent,
+        cx: &mut Context<Self>,
+    ) {
+        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<Arc<Path>> = 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<Vec<ThreadMetadata>> {
+        self.select::<ThreadMetadata>(
+            "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<str>, i32) = Column::column(statement, start_index)?;
+        let (agent_id, next): (Option<String>, 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<String>, i32) = Column::column(statement, next)?;
+        let (folder_paths_str, next): (Option<String>, i32) = Column::column(statement, next)?;
+        let (folder_paths_order_str, next): (Option<String>, 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<Utc>) -> 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");
+    }
+}

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| {

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,

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,

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,

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(),

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<SharedString>) -> 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<ExternalAgentServerName> for SharedString {
-    fn from(value: ExternalAgentServerName) -> Self {
+impl From<AgentId> for SharedString {
+    fn from(value: AgentId) -> Self {
         value.0
     }
 }
 
-impl std::borrow::Borrow<str> for ExternalAgentServerName {
+impl AsRef<str> for AgentId {
+    fn as_ref(&self) -> &str {
+        &self.0
+    }
+}
+
+impl std::borrow::Borrow<str> for AgentId {
     fn borrow(&self) -> &str {
         &self.0
     }
@@ -163,7 +178,7 @@ impl ExternalAgentEntry {
 
 pub struct AgentServerStore {
     state: AgentServerStoreState,
-    pub external_agents: HashMap<ExternalAgentServerName, ExternalAgentEntry>,
+    pub external_agents: HashMap<AgentId, ExternalAgentEntry>,
 }
 
 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<SharedString> {
+    pub fn agent_icon(&self, name: &AgentId) -> Option<SharedString> {
         self.external_agents
             .get(name)
             .and_then(|entry| entry.icon.clone())
     }
 
-    pub fn agent_source(&self, name: &ExternalAgentServerName) -> Option<ExternalAgentSource> {
+    pub fn agent_source(&self, name: &AgentId) -> Option<ExternalAgentSource> {
         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<SharedString> {
+    pub fn agent_display_name(&self, name: &AgentId) -> Option<SharedString> {
         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<Item = &ExternalAgentServerName> {
+    pub fn external_agents(&self) -> impl Iterator<Item = &AgentId> {
         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<dyn ExternalAgentServer>,
                             source,
@@ -877,10 +892,7 @@ impl AgentServerStore {
         Ok(())
     }
 
-    pub fn get_extension_id_for_agent(
-        &mut self,
-        name: &ExternalAgentServerName,
-    ) -> Option<Arc<str>> {
+    pub fn get_extension_id_for_agent(&mut self, name: &AgentId) -> Option<Arc<str>> {
         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<RemoteClient>,
-    name: ExternalAgentServerName,
+    name: AgentId,
     new_version_available_tx: Option<watch::Sender<Option<String>>>,
 }
 
@@ -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<String, CustomAgentServerSettings>);

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},

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<dyn ExternalAgentServer>,
             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<dyn ExternalAgentServer>,
             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<dyn ExternalAgentServer>,
             ExternalAgentSource::Custom,

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<dyn ExternalAgentServer>,
             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<dyn ExternalAgentServer>,
             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<dyn ExternalAgentServer>,
             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

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<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
-        self.paths.serialize(serializer)
-    }
-}
-
-impl<'de> Deserialize<'de> for PathList {
-    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
-        let paths: Vec<PathBuf> = Vec::deserialize(deserializer)?;
-        Ok(PathList::new(&paths))
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;

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()
     }
 

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<String>,
+    pub title: SharedString,
+    pub updated_at: DateTime<Utc>,
+    pub created_at: Option<DateTime<Utc>>,
+    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<Vec<SidebarThreadRow>> {
+        self.select::<SidebarThreadRow>(
+            "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<Vec<SidebarThreadRow>> {
+        let serialized = paths.serialize();
+        self.select_bound::<String, SidebarThreadRow>(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<Option<SidebarThreadRow>> {
+        let id = session_id.0.clone();
+        self.select_row_bound::<Arc<str>, 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<usize> {
+        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<str>, i32) = Column::column(statement, start_index)?;
+        let (agent_name, next): (Option<String>, 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<String>, i32) = Column::column(statement, next)?;
+        let (folder_paths_str, next): (Option<String>, i32) = Column::column(statement, next)?;
+        let (folder_paths_order_str, next): (Option<String>, 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<PathList, Vec<SidebarThreadRow>> = 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<acp::SessionId> = 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<String>` 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) |