Store ACP thread metadata (#51657)

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

Release Notes:

- N/A

---------

Co-authored-by: cameron <cameron.studdstreet@gmail.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-03-16 17:23:02 +01:00
1 parent 6a9a7c0014
commit 015225196d
38 files changed
+2132 -1089

No files matched your search

+89 -30
View File
@@ -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();
+16 -16
View File
@@ -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,
+7 -7
View File
@@ -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()
}
+47 -17
View File
@@ -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();
+2 -5
View File
@@ -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]
+4 -3
View File
@@ -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 {
+8 -8
View File
@@ -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();
-64
View File
@@ -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());
});
}
}
+38 -24
View File
@@ -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,
+3 -3
View File
@@ -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,
+60 -56
View File
@@ -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 { .. }
));
});
+6 -3
View File
@@ -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(
+2 -2
View File
@@ -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();
}
+11 -7
View File
@@ -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();
+70 -66
View File
@@ -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(),
},
);
}
+22 -13
View File
@@ -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(),
},
);
}
+125 -231
View File
@@ -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::FailedToInstall(_) => {
format!("Failed to Install {}", self.agent.name()).into()
LoadError::Unsupported { .. } => {
format!("Upgrade {}", self.agent.agent_id()).into()
}
LoadError::Exited { .. } => format!("{} Exited", self.agent.name()).into(),
LoadError::Other(_) => format!("Error Loading {}", self.agent.name()).into(),
LoadError::FailedToInstall(_) => {
format!("Failed to Install {}", self.agent.agent_id()).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()
}
}
+12 -10
View File
@@ -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();
+8 -7
View File
@@ -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
})
}
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -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()
}
+7 -7
View File
@@ -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,
+1 -1
View File
@@ -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");
}
}
+11 -11
View File
@@ -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,
+6 -1
View File
@@ -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,
+6 -6
View File
@@ -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(),
+44 -32
View File
@@ -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 std::fmt::Display for ExternalAgentServerName {
impl AgentId {
pub fn new(id: impl Into<SharedString>) -> Self {
AgentId(id.into())
}
}
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>);
+1 -3
View File
@@ -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
+2 -15
View File
@@ -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::*;
+4 -3
View File
@@ -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()
}
+580
View File
@@ -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) |