Detailed changes
@@ -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();
@@ -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,
@@ -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()
}
@@ -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();
@@ -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]
@@ -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 {
@@ -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();
@@ -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());
- });
- }
}
@@ -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,
@@ -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,
@@ -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 { .. }
));
});
@@ -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(
@@ -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(
@@ -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();
}
@@ -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();
@@ -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(),
},
);
}
@@ -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(),
},
);
}
@@ -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,
@@ -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()
}
}
@@ -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();
@@ -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
})
}
@@ -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.
@@ -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()
}
@@ -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,
@@ -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,
@@ -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");
+ }
+}
@@ -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| {
@@ -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,
@@ -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,
@@ -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,
@@ -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(),
@@ -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>);
@@ -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},
@@ -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,
@@ -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
@@ -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::*;
@@ -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()
}
@@ -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) |