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:
38 files changed
+2132
-1089
No files matched your search
@@ -31,6 +31,7 @@ use task::{Shell, ShellBuilder};
|
||||
pub use terminal::*;
|
||||
use text::Bias;
|
||||
use ui::App;
|
||||
use util::path_list::PathList;
|
||||
use util::{ResultExt, get_default_system_shell_preferring_bash, paths::PathStyle};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -953,7 +954,7 @@ struct RunningTurn {
|
||||
|
||||
pub struct AcpThread {
|
||||
session_id: acp::SessionId,
|
||||
cwd: Option<PathBuf>,
|
||||
work_dirs: Option<PathList>,
|
||||
parent_session_id: Option<acp::SessionId>,
|
||||
title: SharedString,
|
||||
provisional_title: Option<SharedString>,
|
||||
@@ -1119,7 +1120,7 @@ impl AcpThread {
|
||||
pub fn new(
|
||||
parent_session_id: Option<acp::SessionId>,
|
||||
title: impl Into<SharedString>,
|
||||
cwd: Option<PathBuf>,
|
||||
work_dirs: Option<PathList>,
|
||||
connection: Rc<dyn AgentConnection>,
|
||||
project: Entity<Project>,
|
||||
action_log: Entity<ActionLog>,
|
||||
@@ -1140,7 +1141,7 @@ impl AcpThread {
|
||||
|
||||
Self {
|
||||
parent_session_id,
|
||||
cwd,
|
||||
work_dirs,
|
||||
action_log,
|
||||
shared_buffers: Default::default(),
|
||||
entries: Default::default(),
|
||||
@@ -1219,8 +1220,8 @@ impl AcpThread {
|
||||
&self.session_id
|
||||
}
|
||||
|
||||
pub fn cwd(&self) -> Option<&PathBuf> {
|
||||
self.cwd.as_ref()
|
||||
pub fn work_dirs(&self) -> Option<&PathList> {
|
||||
self.work_dirs.as_ref()
|
||||
}
|
||||
|
||||
pub fn status(&self) -> ThreadStatus {
|
||||
@@ -2858,7 +2859,7 @@ mod tests {
|
||||
use futures::{channel::mpsc, future::LocalBoxFuture, select};
|
||||
use gpui::{App, AsyncApp, TestAppContext, WeakEntity};
|
||||
use indoc::indoc;
|
||||
use project::{FakeFs, Fs};
|
||||
use project::{AgentId, FakeFs, Fs};
|
||||
use rand::{distr, prelude::*};
|
||||
use serde_json::json;
|
||||
use settings::SettingsStore;
|
||||
@@ -2871,7 +2872,7 @@ mod tests {
|
||||
sync::atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst},
|
||||
time::Duration,
|
||||
};
|
||||
use util::path;
|
||||
use util::{path, path_list::PathList};
|
||||
|
||||
fn init_test(cx: &mut TestAppContext) {
|
||||
env_logger::try_init().ok();
|
||||
@@ -2889,7 +2890,13 @@ mod tests {
|
||||
let project = Project::test(fs, [], cx).await;
|
||||
let connection = Rc::new(FakeAgentConnection::new());
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, std::path::Path::new(path!("/test")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(
|
||||
project,
|
||||
PathList::new(&[std::path::Path::new(path!("/test"))]),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -2953,7 +2960,13 @@ mod tests {
|
||||
let project = Project::test(fs, [], cx).await;
|
||||
let connection = Rc::new(FakeAgentConnection::new());
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, std::path::Path::new(path!("/test")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(
|
||||
project,
|
||||
PathList::new(&[std::path::Path::new(path!("/test"))]),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -3041,7 +3054,13 @@ mod tests {
|
||||
let project = Project::test(fs, [], cx).await;
|
||||
let connection = Rc::new(FakeAgentConnection::new());
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project.clone(), Path::new(path!("/test")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(
|
||||
project.clone(),
|
||||
PathList::new(&[Path::new(path!("/test"))]),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -3152,7 +3171,9 @@ mod tests {
|
||||
let project = Project::test(fs, [], cx).await;
|
||||
let connection = Rc::new(FakeAgentConnection::new());
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -3246,7 +3267,9 @@ mod tests {
|
||||
));
|
||||
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -3327,7 +3350,9 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -3368,7 +3393,9 @@ mod tests {
|
||||
let connection = Rc::new(FakeAgentConnection::new());
|
||||
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -3443,7 +3470,9 @@ mod tests {
|
||||
let connection = Rc::new(FakeAgentConnection::new());
|
||||
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -3517,7 +3546,9 @@ mod tests {
|
||||
let connection = Rc::new(FakeAgentConnection::new());
|
||||
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -3565,7 +3596,9 @@ mod tests {
|
||||
}));
|
||||
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -3656,7 +3689,9 @@ mod tests {
|
||||
}));
|
||||
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -3715,7 +3750,9 @@ mod tests {
|
||||
}
|
||||
}));
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -3888,7 +3925,9 @@ mod tests {
|
||||
}));
|
||||
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -3964,7 +4003,9 @@ mod tests {
|
||||
}));
|
||||
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -4037,7 +4078,9 @@ mod tests {
|
||||
}
|
||||
}));
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -4158,6 +4201,10 @@ mod tests {
|
||||
}
|
||||
|
||||
impl AgentConnection for FakeAgentConnection {
|
||||
fn agent_id(&self) -> AgentId {
|
||||
AgentId::new("fake")
|
||||
}
|
||||
|
||||
fn telemetry_id(&self) -> SharedString {
|
||||
"fake".into()
|
||||
}
|
||||
@@ -4169,7 +4216,7 @@ mod tests {
|
||||
fn new_session(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
cwd: &Path,
|
||||
work_dirs: PathList,
|
||||
cx: &mut App,
|
||||
) -> Task<gpui::Result<Entity<AcpThread>>> {
|
||||
let session_id = acp::SessionId::new(
|
||||
@@ -4184,7 +4231,7 @@ mod tests {
|
||||
AcpThread::new(
|
||||
None,
|
||||
"Test",
|
||||
Some(cwd.to_path_buf()),
|
||||
Some(work_dirs),
|
||||
self.clone(),
|
||||
project,
|
||||
action_log,
|
||||
@@ -4283,7 +4330,9 @@ mod tests {
|
||||
let project = Project::test(fs, [], cx).await;
|
||||
let connection = Rc::new(FakeAgentConnection::new());
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -4349,7 +4398,9 @@ mod tests {
|
||||
let project = Project::test(fs, [], cx).await;
|
||||
let connection = Rc::new(FakeAgentConnection::new());
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -4662,7 +4713,9 @@ mod tests {
|
||||
));
|
||||
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -4736,7 +4789,9 @@ mod tests {
|
||||
}));
|
||||
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -4819,7 +4874,9 @@ mod tests {
|
||||
));
|
||||
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -4867,7 +4924,9 @@ mod tests {
|
||||
let set_title_calls = connection.set_title_calls.clone();
|
||||
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
|
||||
.update(|cx| {
|
||||
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -5,17 +5,11 @@ use chrono::{DateTime, Utc};
|
||||
use collections::IndexMap;
|
||||
use gpui::{Entity, SharedString, Task};
|
||||
use language_model::LanguageModelProviderId;
|
||||
use project::Project;
|
||||
use project::{AgentId, Project};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
any::Any,
|
||||
error::Error,
|
||||
fmt,
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
};
|
||||
use std::{any::Any, error::Error, fmt, path::PathBuf, rc::Rc, sync::Arc};
|
||||
use ui::{App, IconName};
|
||||
use util::path_list::PathList;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
@@ -28,12 +22,14 @@ impl UserMessageId {
|
||||
}
|
||||
|
||||
pub trait AgentConnection {
|
||||
fn agent_id(&self) -> AgentId;
|
||||
|
||||
fn telemetry_id(&self) -> SharedString;
|
||||
|
||||
fn new_session(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
cwd: &Path,
|
||||
_work_dirs: PathList,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<AcpThread>>>;
|
||||
|
||||
@@ -47,7 +43,7 @@ pub trait AgentConnection {
|
||||
self: Rc<Self>,
|
||||
_session_id: acp::SessionId,
|
||||
_project: Entity<Project>,
|
||||
_cwd: &Path,
|
||||
_work_dirs: PathList,
|
||||
_title: Option<SharedString>,
|
||||
_cx: &mut App,
|
||||
) -> Task<Result<Entity<AcpThread>>> {
|
||||
@@ -78,7 +74,7 @@ pub trait AgentConnection {
|
||||
self: Rc<Self>,
|
||||
_session_id: acp::SessionId,
|
||||
_project: Entity<Project>,
|
||||
_cwd: &Path,
|
||||
_work_dirs: PathList,
|
||||
_title: Option<SharedString>,
|
||||
_cx: &mut App,
|
||||
) -> Task<Result<Entity<AcpThread>>> {
|
||||
@@ -243,7 +239,7 @@ impl AgentSessionListResponse {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AgentSessionInfo {
|
||||
pub session_id: acp::SessionId,
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub work_dirs: Option<PathList>,
|
||||
pub title: Option<SharedString>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
@@ -254,7 +250,7 @@ impl AgentSessionInfo {
|
||||
pub fn new(session_id: impl Into<acp::SessionId>) -> Self {
|
||||
Self {
|
||||
session_id: session_id.into(),
|
||||
cwd: None,
|
||||
work_dirs: None,
|
||||
title: None,
|
||||
updated_at: None,
|
||||
created_at: None,
|
||||
@@ -609,6 +605,10 @@ mod test_support {
|
||||
}
|
||||
|
||||
impl AgentConnection for StubAgentConnection {
|
||||
fn agent_id(&self) -> AgentId {
|
||||
AgentId::new("stub")
|
||||
}
|
||||
|
||||
fn telemetry_id(&self) -> SharedString {
|
||||
"stub".into()
|
||||
}
|
||||
@@ -627,7 +627,7 @@ mod test_support {
|
||||
fn new_session(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
cwd: &Path,
|
||||
work_dirs: PathList,
|
||||
cx: &mut gpui::App,
|
||||
) -> Task<gpui::Result<Entity<AcpThread>>> {
|
||||
static NEXT_SESSION_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
@@ -638,7 +638,7 @@ mod test_support {
|
||||
AcpThread::new(
|
||||
None,
|
||||
"Test",
|
||||
Some(cwd.to_path_buf()),
|
||||
Some(work_dirs),
|
||||
self.clone(),
|
||||
project,
|
||||
action_log,
|
||||
|
||||
@@ -14,7 +14,7 @@ use gpui::{
|
||||
};
|
||||
use language::LanguageRegistry;
|
||||
use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle};
|
||||
use project::Project;
|
||||
use project::{AgentId, Project};
|
||||
use settings::Settings;
|
||||
use theme::ThemeSettings;
|
||||
use ui::{CopyButton, Tooltip, WithScrollbar, prelude::*};
|
||||
@@ -48,7 +48,7 @@ pub struct AcpConnectionRegistry {
|
||||
}
|
||||
|
||||
struct ActiveConnection {
|
||||
server_name: SharedString,
|
||||
agent_id: AgentId,
|
||||
connection: Weak<acp::ClientSideConnection>,
|
||||
}
|
||||
|
||||
@@ -65,12 +65,12 @@ impl AcpConnectionRegistry {
|
||||
|
||||
pub fn set_active_connection(
|
||||
&self,
|
||||
server_name: impl Into<SharedString>,
|
||||
agent_id: AgentId,
|
||||
connection: &Rc<acp::ClientSideConnection>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.active_connection.replace(Some(ActiveConnection {
|
||||
server_name: server_name.into(),
|
||||
agent_id,
|
||||
connection: Rc::downgrade(connection),
|
||||
}));
|
||||
cx.notify();
|
||||
@@ -87,7 +87,7 @@ struct AcpTools {
|
||||
}
|
||||
|
||||
struct WatchedConnection {
|
||||
server_name: SharedString,
|
||||
agent_id: AgentId,
|
||||
messages: Vec<WatchedConnectionMessage>,
|
||||
list_state: ListState,
|
||||
connection: Weak<acp::ClientSideConnection>,
|
||||
@@ -144,7 +144,7 @@ impl AcpTools {
|
||||
});
|
||||
|
||||
self.watched_connection = Some(WatchedConnection {
|
||||
server_name: active_connection.server_name.clone(),
|
||||
agent_id: active_connection.agent_id.clone(),
|
||||
messages: vec![],
|
||||
list_state: ListState::new(0, ListAlignment::Bottom, px(2048.)),
|
||||
connection: active_connection.connection.clone(),
|
||||
@@ -483,7 +483,7 @@ impl Item for AcpTools {
|
||||
"ACP: {}",
|
||||
self.watched_connection
|
||||
.as_ref()
|
||||
.map_or("Disconnected", |connection| &connection.server_name)
|
||||
.map_or("Disconnected", |connection| connection.agent_id.0.as_ref())
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
+47
-17
@@ -41,7 +41,7 @@ use gpui::{
|
||||
WeakEntity,
|
||||
};
|
||||
use language_model::{IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelRegistry};
|
||||
use project::{Project, ProjectItem, ProjectPath, Worktree};
|
||||
use project::{AgentId, Project, ProjectItem, ProjectPath, Worktree};
|
||||
use prompt_store::{
|
||||
ProjectContext, PromptStore, RULES_FILE_NAMES, RulesFileContext, UserRulesContext,
|
||||
WorktreeContext,
|
||||
@@ -49,9 +49,9 @@ use prompt_store::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{LanguageModelSelection, update_settings_file};
|
||||
use std::any::Any;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use util::ResultExt;
|
||||
use util::path_list::PathList;
|
||||
use util::rel_path::RelPath;
|
||||
@@ -1381,7 +1381,13 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector {
|
||||
}
|
||||
}
|
||||
|
||||
pub static ZED_AGENT_ID: LazyLock<AgentId> = LazyLock::new(|| AgentId::new("Zed Agent"));
|
||||
|
||||
impl acp_thread::AgentConnection for NativeAgentConnection {
|
||||
fn agent_id(&self) -> AgentId {
|
||||
ZED_AGENT_ID.clone()
|
||||
}
|
||||
|
||||
fn telemetry_id(&self) -> SharedString {
|
||||
"zed".into()
|
||||
}
|
||||
@@ -1389,10 +1395,10 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
|
||||
fn new_session(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
cwd: &Path,
|
||||
work_dirs: PathList,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<acp_thread::AcpThread>>> {
|
||||
log::debug!("Creating new thread for project at: {cwd:?}");
|
||||
log::debug!("Creating new thread for project at: {work_dirs:?}");
|
||||
Task::ready(Ok(self
|
||||
.0
|
||||
.update(cx, |agent, cx| agent.new_session(project, cx))))
|
||||
@@ -1406,7 +1412,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
|
||||
self: Rc<Self>,
|
||||
session_id: acp::SessionId,
|
||||
project: Entity<Project>,
|
||||
_cwd: &Path,
|
||||
_work_dirs: PathList,
|
||||
_title: Option<SharedString>,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<acp_thread::AcpThread>>> {
|
||||
@@ -2079,6 +2085,8 @@ impl TerminalHandle for AcpTerminalHandle {
|
||||
|
||||
#[cfg(test)]
|
||||
mod internal_tests {
|
||||
use std::path::Path;
|
||||
|
||||
use super::*;
|
||||
use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelInfo, MentionUri};
|
||||
use fs::FakeFs;
|
||||
@@ -2111,7 +2119,13 @@ mod internal_tests {
|
||||
// Creating a session registers the project and triggers context building.
|
||||
let connection = NativeAgentConnection(agent.clone());
|
||||
let _acp_thread = cx
|
||||
.update(|cx| Rc::new(connection).new_session(project.clone(), Path::new("/"), cx))
|
||||
.update(|cx| {
|
||||
Rc::new(connection).new_session(
|
||||
project.clone(),
|
||||
PathList::new(&[Path::new("/")]),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
@@ -2180,7 +2194,11 @@ mod internal_tests {
|
||||
// Create a thread/session
|
||||
let acp_thread = cx
|
||||
.update(|cx| {
|
||||
Rc::new(connection.clone()).new_session(project.clone(), Path::new("/a"), cx)
|
||||
Rc::new(connection.clone()).new_session(
|
||||
project.clone(),
|
||||
PathList::new(&[Path::new("/a")]),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -2251,7 +2269,11 @@ mod internal_tests {
|
||||
// Create a thread/session
|
||||
let acp_thread = cx
|
||||
.update(|cx| {
|
||||
Rc::new(connection.clone()).new_session(project.clone(), Path::new("/a"), cx)
|
||||
Rc::new(connection.clone()).new_session(
|
||||
project.clone(),
|
||||
PathList::new(&[Path::new("/a")]),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -2343,7 +2365,11 @@ mod internal_tests {
|
||||
|
||||
let acp_thread = cx
|
||||
.update(|cx| {
|
||||
Rc::new(connection.clone()).new_session(project.clone(), Path::new("/a"), cx)
|
||||
Rc::new(connection.clone()).new_session(
|
||||
project.clone(),
|
||||
PathList::new(&[Path::new("/a")]),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -2450,9 +2476,11 @@ mod internal_tests {
|
||||
// Create a thread and select the thinking model.
|
||||
let acp_thread = cx
|
||||
.update(|cx| {
|
||||
connection
|
||||
.clone()
|
||||
.new_session(project.clone(), Path::new("/a"), cx)
|
||||
connection.clone().new_session(
|
||||
project.clone(),
|
||||
PathList::new(&[Path::new("/a")]),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -2552,9 +2580,11 @@ mod internal_tests {
|
||||
// Create a thread and select the model.
|
||||
let acp_thread = cx
|
||||
.update(|cx| {
|
||||
connection
|
||||
.clone()
|
||||
.new_session(project.clone(), Path::new("/a"), cx)
|
||||
connection.clone().new_session(
|
||||
project.clone(),
|
||||
PathList::new(&[Path::new("/a")]),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -2645,7 +2675,7 @@ mod internal_tests {
|
||||
.update(|cx| {
|
||||
connection
|
||||
.clone()
|
||||
.new_session(project.clone(), Path::new(""), cx)
|
||||
.new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -25,11 +25,10 @@ pub type DbMessage = crate::Message;
|
||||
pub type DbSummary = crate::legacy_thread::DetailedSummaryState;
|
||||
pub type DbLanguageModel = crate::legacy_thread::SerializedLanguageModel;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DbThreadMetadata {
|
||||
pub id: acp::SessionId,
|
||||
pub parent_session_id: Option<acp::SessionId>,
|
||||
#[serde(alias = "summary")]
|
||||
pub title: SharedString,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
@@ -42,7 +41,7 @@ impl From<&DbThreadMetadata> for acp_thread::AgentSessionInfo {
|
||||
fn from(meta: &DbThreadMetadata) -> Self {
|
||||
Self {
|
||||
session_id: meta.id.clone(),
|
||||
cwd: None,
|
||||
work_dirs: Some(meta.folder_paths.clone()),
|
||||
title: Some(meta.title.clone()),
|
||||
updated_at: Some(meta.updated_at),
|
||||
created_at: meta.created_at,
|
||||
@@ -881,7 +880,6 @@ mod tests {
|
||||
|
||||
let threads = database.list_threads().await.unwrap();
|
||||
assert_eq!(threads.len(), 1);
|
||||
assert_eq!(threads[0].folder_paths, folder_paths);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
@@ -901,7 +899,6 @@ mod tests {
|
||||
|
||||
let threads = database.list_threads().await.unwrap();
|
||||
assert_eq!(threads.len(), 1);
|
||||
assert!(threads[0].folder_paths.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,7 +6,8 @@ use agent_settings::AgentSettings;
|
||||
use anyhow::Result;
|
||||
use collections::HashSet;
|
||||
use fs::Fs;
|
||||
use gpui::{App, Entity, SharedString, Task};
|
||||
use gpui::{App, Entity, Task};
|
||||
use project::AgentId;
|
||||
use prompt_store::PromptStore;
|
||||
use settings::{LanguageModelSelection, Settings as _, update_settings_file};
|
||||
|
||||
@@ -25,8 +26,8 @@ impl NativeAgentServer {
|
||||
}
|
||||
|
||||
impl AgentServer for NativeAgentServer {
|
||||
fn name(&self) -> SharedString {
|
||||
"Zed Agent".into()
|
||||
fn agent_id(&self) -> AgentId {
|
||||
crate::ZED_AGENT_ID.clone()
|
||||
}
|
||||
|
||||
fn logo(&self) -> ui::IconName {
|
||||
|
||||
@@ -3177,7 +3177,7 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
|
||||
let fake_fs = cx.update(|cx| fs::FakeFs::new(cx.background_executor().clone()));
|
||||
fake_fs.insert_tree(path!("/test"), json!({})).await;
|
||||
let project = Project::test(fake_fs.clone(), [Path::new("/test")], cx).await;
|
||||
let cwd = Path::new("/test");
|
||||
let cwd = PathList::new(&[Path::new("/test")]);
|
||||
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
||||
|
||||
// Create agent and connection
|
||||
@@ -4389,7 +4389,7 @@ async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) {
|
||||
.update(|cx| {
|
||||
connection
|
||||
.clone()
|
||||
.new_session(project.clone(), Path::new(""), cx)
|
||||
.new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -4524,7 +4524,7 @@ async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppCon
|
||||
.update(|cx| {
|
||||
connection
|
||||
.clone()
|
||||
.new_session(project.clone(), Path::new(""), cx)
|
||||
.new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -4672,7 +4672,7 @@ async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAp
|
||||
.update(|cx| {
|
||||
connection
|
||||
.clone()
|
||||
.new_session(project.clone(), Path::new(""), cx)
|
||||
.new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -4802,7 +4802,7 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) {
|
||||
.update(|cx| {
|
||||
connection
|
||||
.clone()
|
||||
.new_session(project.clone(), Path::new(""), cx)
|
||||
.new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -5174,7 +5174,7 @@ async fn test_subagent_context_window_warning(cx: &mut TestAppContext) {
|
||||
.update(|cx| {
|
||||
connection
|
||||
.clone()
|
||||
.new_session(project.clone(), Path::new(""), cx)
|
||||
.new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -5300,7 +5300,7 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu
|
||||
.update(|cx| {
|
||||
connection
|
||||
.clone()
|
||||
.new_session(project.clone(), Path::new(""), cx)
|
||||
.new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -5474,7 +5474,7 @@ async fn test_subagent_error_propagation(cx: &mut TestAppContext) {
|
||||
.update(|cx| {
|
||||
connection
|
||||
.clone()
|
||||
.new_session(project.clone(), Path::new(""), cx)
|
||||
.new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::{DbThread, DbThreadMetadata, ThreadsDatabase};
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::{Result, anyhow};
|
||||
use gpui::{App, Context, Entity, Global, Task, prelude::*};
|
||||
use std::collections::HashMap;
|
||||
use util::path_list::PathList;
|
||||
|
||||
struct GlobalThreadStore(Entity<ThreadStore>);
|
||||
@@ -11,7 +10,6 @@ impl Global for GlobalThreadStore {}
|
||||
|
||||
pub struct ThreadStore {
|
||||
threads: Vec<DbThreadMetadata>,
|
||||
threads_by_paths: HashMap<PathList, Vec<usize>>,
|
||||
}
|
||||
|
||||
impl ThreadStore {
|
||||
@@ -31,7 +29,6 @@ impl ThreadStore {
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
let this = Self {
|
||||
threads: Vec::new(),
|
||||
threads_by_paths: HashMap::default(),
|
||||
};
|
||||
this.reload(cx);
|
||||
this
|
||||
@@ -97,16 +94,10 @@ impl ThreadStore {
|
||||
let all_threads = database.list_threads().await?;
|
||||
this.update(cx, |this, cx| {
|
||||
this.threads.clear();
|
||||
this.threads_by_paths.clear();
|
||||
for thread in all_threads {
|
||||
if thread.parent_session_id.is_some() {
|
||||
continue;
|
||||
}
|
||||
let index = this.threads.len();
|
||||
this.threads_by_paths
|
||||
.entry(thread.folder_paths.clone())
|
||||
.or_default()
|
||||
.push(index);
|
||||
this.threads.push(thread);
|
||||
}
|
||||
cx.notify();
|
||||
@@ -122,15 +113,6 @@ impl ThreadStore {
|
||||
pub fn entries(&self) -> impl Iterator<Item = DbThreadMetadata> + '_ {
|
||||
self.threads.iter().cloned()
|
||||
}
|
||||
|
||||
/// Returns threads whose folder_paths match the given paths exactly.
|
||||
/// Uses a cached index for O(1) lookup per path list.
|
||||
pub fn threads_for_paths(&self, paths: &PathList) -> impl Iterator<Item = &DbThreadMetadata> {
|
||||
self.threads_by_paths
|
||||
.get(paths)
|
||||
.into_iter()
|
||||
.flat_map(|indices| indices.iter().map(|&index| &self.threads[index]))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -306,50 +288,4 @@ mod tests {
|
||||
assert_eq!(entries[0].id, first_id);
|
||||
assert_eq!(entries[1].id, second_id);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_threads_for_paths_filters_correctly(cx: &mut TestAppContext) {
|
||||
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
||||
cx.run_until_parked();
|
||||
|
||||
let project_a_paths = PathList::new(&[std::path::PathBuf::from("/home/user/project-a")]);
|
||||
let project_b_paths = PathList::new(&[std::path::PathBuf::from("/home/user/project-b")]);
|
||||
|
||||
let thread_a = make_thread(
|
||||
"Thread in A",
|
||||
Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
|
||||
);
|
||||
let thread_b = make_thread(
|
||||
"Thread in B",
|
||||
Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap(),
|
||||
);
|
||||
let thread_a_id = session_id("thread-a");
|
||||
let thread_b_id = session_id("thread-b");
|
||||
|
||||
let save_a = thread_store.update(cx, |store, cx| {
|
||||
store.save_thread(thread_a_id.clone(), thread_a, project_a_paths.clone(), cx)
|
||||
});
|
||||
save_a.await.unwrap();
|
||||
|
||||
let save_b = thread_store.update(cx, |store, cx| {
|
||||
store.save_thread(thread_b_id.clone(), thread_b, project_b_paths.clone(), cx)
|
||||
});
|
||||
save_b.await.unwrap();
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
thread_store.read_with(cx, |store, _cx| {
|
||||
let a_threads: Vec<_> = store.threads_for_paths(&project_a_paths).collect();
|
||||
assert_eq!(a_threads.len(), 1);
|
||||
assert_eq!(a_threads[0].id, thread_a_id);
|
||||
|
||||
let b_threads: Vec<_> = store.threads_for_paths(&project_b_paths).collect();
|
||||
assert_eq!(b_threads.len(), 1);
|
||||
assert_eq!(b_threads[0].id, thread_b_id);
|
||||
|
||||
let nonexistent = PathList::new(&[std::path::PathBuf::from("/nonexistent")]);
|
||||
let no_threads: Vec<_> = store.threads_for_paths(&nonexistent).collect();
|
||||
assert!(no_threads.is_empty());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,18 +9,19 @@ use anyhow::anyhow;
|
||||
use collections::HashMap;
|
||||
use futures::AsyncBufReadExt as _;
|
||||
use futures::io::BufReader;
|
||||
use project::Project;
|
||||
use project::agent_server_store::{AgentServerCommand, GEMINI_NAME};
|
||||
use project::agent_server_store::{AgentServerCommand, GEMINI_ID};
|
||||
use project::{AgentId, Project};
|
||||
use serde::Deserialize;
|
||||
use settings::Settings as _;
|
||||
use task::ShellBuilder;
|
||||
use util::ResultExt as _;
|
||||
use util::path_list::PathList;
|
||||
use util::process::Child;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::rc::Rc;
|
||||
use std::{any::Any, cell::RefCell};
|
||||
use std::{path::Path, rc::Rc};
|
||||
use thiserror::Error;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
@@ -35,7 +36,7 @@ use terminal::terminal_settings::{AlternateScroll, CursorShape, TerminalSettings
|
||||
pub struct UnsupportedVersion;
|
||||
|
||||
pub struct AcpConnection {
|
||||
server_name: SharedString,
|
||||
id: AgentId,
|
||||
display_name: SharedString,
|
||||
telemetry_id: SharedString,
|
||||
connection: Rc<acp::ClientSideConnection>,
|
||||
@@ -124,7 +125,7 @@ impl AgentSessionList for AcpSessionList {
|
||||
.into_iter()
|
||||
.map(|s| AgentSessionInfo {
|
||||
session_id: s.session_id,
|
||||
cwd: Some(s.cwd),
|
||||
work_dirs: Some(PathList::new(&[s.cwd])),
|
||||
title: s.title.map(Into::into),
|
||||
updated_at: s.updated_at.and_then(|date_str| {
|
||||
chrono::DateTime::parse_from_rfc3339(&date_str)
|
||||
@@ -158,7 +159,7 @@ impl AgentSessionList for AcpSessionList {
|
||||
}
|
||||
|
||||
pub async fn connect(
|
||||
server_name: SharedString,
|
||||
agent_id: AgentId,
|
||||
display_name: SharedString,
|
||||
command: AgentServerCommand,
|
||||
default_mode: Option<acp::SessionModeId>,
|
||||
@@ -167,7 +168,7 @@ pub async fn connect(
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Rc<dyn AgentConnection>> {
|
||||
let conn = AcpConnection::stdio(
|
||||
server_name,
|
||||
agent_id,
|
||||
display_name,
|
||||
command.clone(),
|
||||
default_mode,
|
||||
@@ -183,7 +184,7 @@ const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::ProtocolVersion::V1
|
||||
|
||||
impl AcpConnection {
|
||||
pub async fn stdio(
|
||||
server_name: SharedString,
|
||||
agent_id: AgentId,
|
||||
display_name: SharedString,
|
||||
command: AgentServerCommand,
|
||||
default_mode: Option<acp::SessionModeId>,
|
||||
@@ -270,7 +271,7 @@ impl AcpConnection {
|
||||
|
||||
cx.update(|cx| {
|
||||
AcpConnectionRegistry::default_global(cx).update(cx, |registry, cx| {
|
||||
registry.set_active_connection(server_name.clone(), &connection, cx)
|
||||
registry.set_active_connection(agent_id.clone(), &connection, cx)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -305,7 +306,7 @@ impl AcpConnection {
|
||||
// Use the one the agent provides if we have one
|
||||
.map(|info| info.name.into())
|
||||
// Otherwise, just use the name
|
||||
.unwrap_or_else(|| server_name.clone());
|
||||
.unwrap_or_else(|| agent_id.0.to_string().into());
|
||||
|
||||
let session_list = if response
|
||||
.agent_capabilities
|
||||
@@ -321,7 +322,7 @@ impl AcpConnection {
|
||||
};
|
||||
|
||||
// TODO: Remove this override once Google team releases their official auth methods
|
||||
let auth_methods = if server_name == GEMINI_NAME {
|
||||
let auth_methods = if agent_id.0.as_ref() == GEMINI_ID {
|
||||
let mut args = command.args.clone();
|
||||
args.retain(|a| a != "--experimental-acp");
|
||||
let value = serde_json::json!({
|
||||
@@ -340,9 +341,9 @@ impl AcpConnection {
|
||||
response.auth_methods
|
||||
};
|
||||
Ok(Self {
|
||||
id: agent_id,
|
||||
auth_methods,
|
||||
connection,
|
||||
server_name,
|
||||
display_name,
|
||||
telemetry_id,
|
||||
sessions,
|
||||
@@ -368,7 +369,7 @@ impl AcpConnection {
|
||||
config_options: &Rc<RefCell<Vec<acp::SessionConfigOption>>>,
|
||||
cx: &mut AsyncApp,
|
||||
) {
|
||||
let name = self.server_name.clone();
|
||||
let id = self.id.clone();
|
||||
let defaults_to_apply: Vec<_> = {
|
||||
let config_opts_ref = config_options.borrow();
|
||||
config_opts_ref
|
||||
@@ -410,7 +411,7 @@ impl AcpConnection {
|
||||
"`{}` is not a valid value for config option `{}` in {}",
|
||||
default_value,
|
||||
config_option.id.0,
|
||||
name
|
||||
id
|
||||
);
|
||||
None
|
||||
}
|
||||
@@ -466,6 +467,10 @@ impl Drop for AcpConnection {
|
||||
}
|
||||
|
||||
impl AgentConnection for AcpConnection {
|
||||
fn agent_id(&self) -> AgentId {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn telemetry_id(&self) -> SharedString {
|
||||
self.telemetry_id.clone()
|
||||
}
|
||||
@@ -473,11 +478,14 @@ impl AgentConnection for AcpConnection {
|
||||
fn new_session(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
cwd: &Path,
|
||||
work_dirs: PathList,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<AcpThread>>> {
|
||||
let name = self.server_name.clone();
|
||||
let cwd = cwd.to_path_buf();
|
||||
// TODO: remove this once ACP supports multiple working directories
|
||||
let Some(cwd) = work_dirs.ordered_paths().next().cloned() else {
|
||||
return Task::ready(Err(anyhow!("Working directory cannot be empty")));
|
||||
};
|
||||
let name = self.id.0.clone();
|
||||
let mcp_servers = mcp_servers_for_project(&project, cx);
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
@@ -575,7 +583,7 @@ impl AgentConnection for AcpConnection {
|
||||
AcpThread::new(
|
||||
None,
|
||||
self.display_name.clone(),
|
||||
Some(cwd),
|
||||
Some(work_dirs),
|
||||
self.clone(),
|
||||
project,
|
||||
action_log,
|
||||
@@ -616,7 +624,7 @@ impl AgentConnection for AcpConnection {
|
||||
self: Rc<Self>,
|
||||
session_id: acp::SessionId,
|
||||
project: Entity<Project>,
|
||||
cwd: &Path,
|
||||
work_dirs: PathList,
|
||||
title: Option<SharedString>,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<AcpThread>>> {
|
||||
@@ -625,8 +633,11 @@ impl AgentConnection for AcpConnection {
|
||||
"Loading sessions is not supported by this agent.".into()
|
||||
))));
|
||||
}
|
||||
// TODO: remove this once ACP supports multiple working directories
|
||||
let Some(cwd) = work_dirs.ordered_paths().next().cloned() else {
|
||||
return Task::ready(Err(anyhow!("Working directory cannot be empty")));
|
||||
};
|
||||
|
||||
let cwd = cwd.to_path_buf();
|
||||
let mcp_servers = mcp_servers_for_project(&project, cx);
|
||||
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
||||
let title = title.unwrap_or_else(|| self.display_name.clone());
|
||||
@@ -634,7 +645,7 @@ impl AgentConnection for AcpConnection {
|
||||
AcpThread::new(
|
||||
None,
|
||||
title,
|
||||
Some(cwd.clone()),
|
||||
Some(work_dirs.clone()),
|
||||
self.clone(),
|
||||
project,
|
||||
action_log,
|
||||
@@ -691,7 +702,7 @@ impl AgentConnection for AcpConnection {
|
||||
self: Rc<Self>,
|
||||
session_id: acp::SessionId,
|
||||
project: Entity<Project>,
|
||||
cwd: &Path,
|
||||
work_dirs: PathList,
|
||||
title: Option<SharedString>,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<AcpThread>>> {
|
||||
@@ -705,8 +716,11 @@ impl AgentConnection for AcpConnection {
|
||||
"Resuming sessions is not supported by this agent.".into()
|
||||
))));
|
||||
}
|
||||
// TODO: remove this once ACP supports multiple working directories
|
||||
let Some(cwd) = work_dirs.ordered_paths().next().cloned() else {
|
||||
return Task::ready(Err(anyhow!("Working directory cannot be empty")));
|
||||
};
|
||||
|
||||
let cwd = cwd.to_path_buf();
|
||||
let mcp_servers = mcp_servers_for_project(&project, cx);
|
||||
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
||||
let title = title.unwrap_or_else(|| self.display_name.clone());
|
||||
@@ -714,7 +728,7 @@ impl AgentConnection for AcpConnection {
|
||||
AcpThread::new(
|
||||
None,
|
||||
title,
|
||||
Some(cwd.clone()),
|
||||
Some(work_dirs),
|
||||
self.clone(),
|
||||
project,
|
||||
action_log,
|
||||
|
||||
@@ -9,11 +9,11 @@ use collections::{HashMap, HashSet};
|
||||
pub use custom::*;
|
||||
use fs::Fs;
|
||||
use http_client::read_no_proxy_from_env;
|
||||
use project::agent_server_store::AgentServerStore;
|
||||
use project::{AgentId, agent_server_store::AgentServerStore};
|
||||
|
||||
use acp_thread::AgentConnection;
|
||||
use anyhow::Result;
|
||||
use gpui::{App, AppContext, Entity, SharedString, Task};
|
||||
use gpui::{App, AppContext, Entity, Task};
|
||||
use settings::SettingsStore;
|
||||
use std::{any::Any, rc::Rc, sync::Arc};
|
||||
|
||||
@@ -38,7 +38,7 @@ impl AgentServerDelegate {
|
||||
|
||||
pub trait AgentServer: Send {
|
||||
fn logo(&self) -> ui::IconName;
|
||||
fn name(&self) -> SharedString;
|
||||
fn agent_id(&self) -> AgentId;
|
||||
fn connect(
|
||||
&self,
|
||||
delegate: AgentServerDelegate,
|
||||
|
||||
@@ -5,10 +5,10 @@ use anyhow::{Context as _, Result};
|
||||
use collections::HashSet;
|
||||
use credentials_provider::CredentialsProvider;
|
||||
use fs::Fs;
|
||||
use gpui::{App, AppContext as _, SharedString, Task};
|
||||
use gpui::{App, AppContext as _, Task};
|
||||
use language_model::{ApiKey, EnvVar};
|
||||
use project::agent_server_store::{
|
||||
AllAgentServersSettings, CLAUDE_AGENT_NAME, CODEX_NAME, ExternalAgentServerName, GEMINI_NAME,
|
||||
AgentId, AllAgentServersSettings, CLAUDE_AGENT_ID, CODEX_ID, GEMINI_ID,
|
||||
};
|
||||
use settings::{SettingsStore, update_settings_file};
|
||||
use std::{rc::Rc, sync::Arc};
|
||||
@@ -16,18 +16,18 @@ use ui::IconName;
|
||||
|
||||
/// A generic agent server implementation for custom user-defined agents
|
||||
pub struct CustomAgentServer {
|
||||
name: SharedString,
|
||||
agent_id: AgentId,
|
||||
}
|
||||
|
||||
impl CustomAgentServer {
|
||||
pub fn new(name: SharedString) -> Self {
|
||||
Self { name }
|
||||
pub fn new(agent_id: AgentId) -> Self {
|
||||
Self { agent_id }
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentServer for CustomAgentServer {
|
||||
fn name(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
fn agent_id(&self) -> AgentId {
|
||||
self.agent_id.clone()
|
||||
}
|
||||
|
||||
fn logo(&self) -> IconName {
|
||||
@@ -38,7 +38,7 @@ impl AgentServer for CustomAgentServer {
|
||||
let settings = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings
|
||||
.get::<AllAgentServersSettings>(None)
|
||||
.get(self.name().as_ref())
|
||||
.get(self.agent_id().0.as_ref())
|
||||
.cloned()
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ impl AgentServer for CustomAgentServer {
|
||||
let settings = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings
|
||||
.get::<AllAgentServersSettings>(None)
|
||||
.get(self.name().as_ref())
|
||||
.get(self.agent_id().0.as_ref())
|
||||
.cloned()
|
||||
});
|
||||
|
||||
@@ -80,7 +80,7 @@ impl AgentServer for CustomAgentServer {
|
||||
fs: Arc<dyn Fs>,
|
||||
cx: &App,
|
||||
) {
|
||||
let name = self.name();
|
||||
let agent_id = self.agent_id();
|
||||
let config_id = config_id.to_string();
|
||||
let value_id = value_id.to_string();
|
||||
|
||||
@@ -88,8 +88,8 @@ impl AgentServer for CustomAgentServer {
|
||||
let settings = settings
|
||||
.agent_servers
|
||||
.get_or_insert_default()
|
||||
.entry(name.to_string())
|
||||
.or_insert_with(|| default_settings_for_agent(&name, cx));
|
||||
.entry(agent_id.0.to_string())
|
||||
.or_insert_with(|| default_settings_for_agent(agent_id, cx));
|
||||
|
||||
match settings {
|
||||
settings::CustomAgentServerSettings::Custom {
|
||||
@@ -124,13 +124,13 @@ impl AgentServer for CustomAgentServer {
|
||||
}
|
||||
|
||||
fn set_default_mode(&self, mode_id: Option<acp::SessionModeId>, fs: Arc<dyn Fs>, cx: &mut App) {
|
||||
let name = self.name();
|
||||
let agent_id = self.agent_id();
|
||||
update_settings_file(fs, cx, move |settings, cx| {
|
||||
let settings = settings
|
||||
.agent_servers
|
||||
.get_or_insert_default()
|
||||
.entry(name.to_string())
|
||||
.or_insert_with(|| default_settings_for_agent(&name, cx));
|
||||
.entry(agent_id.0.to_string())
|
||||
.or_insert_with(|| default_settings_for_agent(agent_id, cx));
|
||||
|
||||
match settings {
|
||||
settings::CustomAgentServerSettings::Custom { default_mode, .. }
|
||||
@@ -146,7 +146,7 @@ impl AgentServer for CustomAgentServer {
|
||||
let settings = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings
|
||||
.get::<AllAgentServersSettings>(None)
|
||||
.get(self.name().as_ref())
|
||||
.get(self.agent_id().as_ref())
|
||||
.cloned()
|
||||
});
|
||||
|
||||
@@ -156,13 +156,13 @@ impl AgentServer for CustomAgentServer {
|
||||
}
|
||||
|
||||
fn set_default_model(&self, model_id: Option<acp::ModelId>, fs: Arc<dyn Fs>, cx: &mut App) {
|
||||
let name = self.name();
|
||||
let agent_id = self.agent_id();
|
||||
update_settings_file(fs, cx, move |settings, cx| {
|
||||
let settings = settings
|
||||
.agent_servers
|
||||
.get_or_insert_default()
|
||||
.entry(name.to_string())
|
||||
.or_insert_with(|| default_settings_for_agent(&name, cx));
|
||||
.entry(agent_id.0.to_string())
|
||||
.or_insert_with(|| default_settings_for_agent(agent_id, cx));
|
||||
|
||||
match settings {
|
||||
settings::CustomAgentServerSettings::Custom { default_model, .. }
|
||||
@@ -178,7 +178,7 @@ impl AgentServer for CustomAgentServer {
|
||||
let settings = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings
|
||||
.get::<AllAgentServersSettings>(None)
|
||||
.get(self.name().as_ref())
|
||||
.get(self.agent_id().as_ref())
|
||||
.cloned()
|
||||
});
|
||||
|
||||
@@ -200,13 +200,13 @@ impl AgentServer for CustomAgentServer {
|
||||
fs: Arc<dyn Fs>,
|
||||
cx: &App,
|
||||
) {
|
||||
let name = self.name();
|
||||
let agent_id = self.agent_id();
|
||||
update_settings_file(fs, cx, move |settings, cx| {
|
||||
let settings = settings
|
||||
.agent_servers
|
||||
.get_or_insert_default()
|
||||
.entry(name.to_string())
|
||||
.or_insert_with(|| default_settings_for_agent(&name, cx));
|
||||
.entry(agent_id.0.to_string())
|
||||
.or_insert_with(|| default_settings_for_agent(agent_id, cx));
|
||||
|
||||
let favorite_models = match settings {
|
||||
settings::CustomAgentServerSettings::Custom {
|
||||
@@ -235,7 +235,7 @@ impl AgentServer for CustomAgentServer {
|
||||
let settings = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings
|
||||
.get::<AllAgentServersSettings>(None)
|
||||
.get(self.name().as_ref())
|
||||
.get(self.agent_id().as_ref())
|
||||
.cloned()
|
||||
});
|
||||
|
||||
@@ -251,15 +251,15 @@ impl AgentServer for CustomAgentServer {
|
||||
fs: Arc<dyn Fs>,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let name = self.name();
|
||||
let agent_id = self.agent_id();
|
||||
let config_id = config_id.to_string();
|
||||
let value_id = value_id.map(|s| s.to_string());
|
||||
update_settings_file(fs, cx, move |settings, cx| {
|
||||
let settings = settings
|
||||
.agent_servers
|
||||
.get_or_insert_default()
|
||||
.entry(name.to_string())
|
||||
.or_insert_with(|| default_settings_for_agent(&name, cx));
|
||||
.entry(agent_id.0.to_string())
|
||||
.or_insert_with(|| default_settings_for_agent(agent_id, cx));
|
||||
|
||||
match settings {
|
||||
settings::CustomAgentServerSettings::Custom {
|
||||
@@ -289,19 +289,19 @@ impl AgentServer for CustomAgentServer {
|
||||
delegate: AgentServerDelegate,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Rc<dyn AgentConnection>>> {
|
||||
let name = self.name();
|
||||
let agent_id = self.agent_id();
|
||||
let display_name = delegate
|
||||
.store
|
||||
.read(cx)
|
||||
.agent_display_name(&ExternalAgentServerName(name.clone()))
|
||||
.unwrap_or_else(|| name.clone());
|
||||
.agent_display_name(&agent_id)
|
||||
.unwrap_or_else(|| agent_id.0.clone());
|
||||
let default_mode = self.default_mode(cx);
|
||||
let default_model = self.default_model(cx);
|
||||
let is_registry_agent = is_registry_agent(&name, cx);
|
||||
let is_registry_agent = is_registry_agent(agent_id.clone(), cx);
|
||||
let default_config_options = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings
|
||||
.get::<AllAgentServersSettings>(None)
|
||||
.get(self.name().as_ref())
|
||||
.get(self.agent_id().as_ref())
|
||||
.map(|s| match s {
|
||||
project::agent_server_store::CustomAgentServerSettings::Custom {
|
||||
default_config_options,
|
||||
@@ -330,11 +330,11 @@ impl AgentServer for CustomAgentServer {
|
||||
extra_env.insert("NO_BROWSER".to_owned(), "1".to_owned());
|
||||
}
|
||||
if is_registry_agent {
|
||||
match name.as_ref() {
|
||||
CLAUDE_AGENT_NAME => {
|
||||
match agent_id.as_ref() {
|
||||
CLAUDE_AGENT_ID => {
|
||||
extra_env.insert("ANTHROPIC_API_KEY".into(), "".into());
|
||||
}
|
||||
CODEX_NAME => {
|
||||
CODEX_ID => {
|
||||
if let Ok(api_key) = std::env::var("CODEX_API_KEY") {
|
||||
extra_env.insert("CODEX_API_KEY".into(), api_key);
|
||||
}
|
||||
@@ -342,7 +342,7 @@ impl AgentServer for CustomAgentServer {
|
||||
extra_env.insert("OPEN_AI_API_KEY".into(), api_key);
|
||||
}
|
||||
}
|
||||
GEMINI_NAME => {
|
||||
GEMINI_ID => {
|
||||
extra_env.insert("SURFACE".to_owned(), "zed".to_owned());
|
||||
}
|
||||
_ => {}
|
||||
@@ -350,18 +350,16 @@ impl AgentServer for CustomAgentServer {
|
||||
}
|
||||
let store = delegate.store.downgrade();
|
||||
cx.spawn(async move |cx| {
|
||||
if is_registry_agent && name.as_ref() == GEMINI_NAME {
|
||||
if is_registry_agent && agent_id.as_ref() == GEMINI_ID {
|
||||
if let Some(api_key) = cx.update(api_key_for_gemini_cli).await.ok() {
|
||||
extra_env.insert("GEMINI_API_KEY".into(), api_key);
|
||||
}
|
||||
}
|
||||
let command = store
|
||||
.update(cx, |store, cx| {
|
||||
let agent = store
|
||||
.get_external_agent(&ExternalAgentServerName(name.clone()))
|
||||
.with_context(|| {
|
||||
format!("Custom agent server `{}` is not registered", name)
|
||||
})?;
|
||||
let agent = store.get_external_agent(&agent_id).with_context(|| {
|
||||
format!("Custom agent server `{}` is not registered", agent_id)
|
||||
})?;
|
||||
anyhow::Ok(agent.get_command(
|
||||
extra_env,
|
||||
delegate.new_version_available,
|
||||
@@ -370,7 +368,7 @@ impl AgentServer for CustomAgentServer {
|
||||
})??
|
||||
.await?;
|
||||
let connection = crate::acp::connect(
|
||||
name,
|
||||
agent_id,
|
||||
display_name,
|
||||
command,
|
||||
default_mode,
|
||||
@@ -405,15 +403,17 @@ fn api_key_for_gemini_cli(cx: &mut App) -> Task<Result<String>> {
|
||||
})
|
||||
}
|
||||
|
||||
fn is_registry_agent(name: &str, cx: &App) -> bool {
|
||||
let is_previous_built_in = matches!(name, CLAUDE_AGENT_NAME | CODEX_NAME | GEMINI_NAME);
|
||||
fn is_registry_agent(agent_id: impl Into<AgentId>, cx: &App) -> bool {
|
||||
let agent_id = agent_id.into();
|
||||
let is_previous_built_in =
|
||||
matches!(agent_id.0.as_ref(), CLAUDE_AGENT_ID | CODEX_ID | GEMINI_ID);
|
||||
let is_in_registry = project::AgentRegistryStore::try_global(cx)
|
||||
.map(|store| store.read(cx).agent(name).is_some())
|
||||
.map(|store| store.read(cx).agent(&agent_id).is_some())
|
||||
.unwrap_or(false);
|
||||
let is_settings_registry = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings
|
||||
.get::<AllAgentServersSettings>(None)
|
||||
.get(name)
|
||||
.get(agent_id.as_ref())
|
||||
.is_some_and(|s| {
|
||||
matches!(
|
||||
s,
|
||||
@@ -424,8 +424,11 @@ fn is_registry_agent(name: &str, cx: &App) -> bool {
|
||||
is_previous_built_in || is_in_registry || is_settings_registry
|
||||
}
|
||||
|
||||
fn default_settings_for_agent(name: &str, cx: &App) -> settings::CustomAgentServerSettings {
|
||||
if is_registry_agent(name, cx) {
|
||||
fn default_settings_for_agent(
|
||||
agent_id: impl Into<AgentId>,
|
||||
cx: &App,
|
||||
) -> settings::CustomAgentServerSettings {
|
||||
if is_registry_agent(agent_id, cx) {
|
||||
settings::CustomAgentServerSettings::Registry {
|
||||
default_model: None,
|
||||
default_mode: None,
|
||||
@@ -455,6 +458,7 @@ mod tests {
|
||||
AgentRegistryStore, RegistryAgent, RegistryAgentMetadata, RegistryNpxAgent,
|
||||
};
|
||||
use settings::Settings as _;
|
||||
use ui::SharedString;
|
||||
|
||||
fn init_test(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
@@ -470,7 +474,7 @@ mod tests {
|
||||
let id = SharedString::from(id.to_string());
|
||||
RegistryAgent::Npx(RegistryNpxAgent {
|
||||
metadata: RegistryAgentMetadata {
|
||||
id: id.clone(),
|
||||
id: AgentId::new(id.clone()),
|
||||
name: id.clone(),
|
||||
description: SharedString::from(""),
|
||||
version: SharedString::from("1.0.0"),
|
||||
@@ -509,9 +513,9 @@ mod tests {
|
||||
fn test_previous_builtins_are_registry(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
cx.update(|cx| {
|
||||
assert!(is_registry_agent(CLAUDE_AGENT_NAME, cx));
|
||||
assert!(is_registry_agent(CODEX_NAME, cx));
|
||||
assert!(is_registry_agent(GEMINI_NAME, cx));
|
||||
assert!(is_registry_agent(CLAUDE_AGENT_ID, cx));
|
||||
assert!(is_registry_agent(CODEX_ID, cx));
|
||||
assert!(is_registry_agent(GEMINI_ID, cx));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -582,15 +586,15 @@ mod tests {
|
||||
init_test(cx);
|
||||
cx.update(|cx| {
|
||||
assert!(matches!(
|
||||
default_settings_for_agent(CODEX_NAME, cx),
|
||||
default_settings_for_agent(CODEX_ID, cx),
|
||||
settings::CustomAgentServerSettings::Registry { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
default_settings_for_agent(CLAUDE_AGENT_NAME, cx),
|
||||
default_settings_for_agent(CLAUDE_AGENT_ID, cx),
|
||||
settings::CustomAgentServerSettings::Registry { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
default_settings_for_agent(GEMINI_NAME, cx),
|
||||
default_settings_for_agent(GEMINI_ID, cx),
|
||||
settings::CustomAgentServerSettings::Registry { .. }
|
||||
));
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
use util::path;
|
||||
use util::path_list::PathList;
|
||||
|
||||
pub async fn test_basic<T, F>(server: F, cx: &mut TestAppContext)
|
||||
where
|
||||
@@ -435,9 +436,11 @@ pub async fn new_test_thread(
|
||||
|
||||
let connection = cx.update(|cx| server.connect(delegate, cx)).await.unwrap();
|
||||
|
||||
cx.update(|cx| connection.new_session(project.clone(), current_dir.as_ref(), cx))
|
||||
.await
|
||||
.unwrap()
|
||||
cx.update(|cx| {
|
||||
connection.new_session(project.clone(), PathList::new(&[current_dir.as_ref()]), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub async fn run_until_first_tool_call(
|
||||
|
||||
@@ -28,7 +28,7 @@ use language_model::{
|
||||
use language_models::AllLanguageModelSettings;
|
||||
use notifications::status_toast::{StatusToast, ToastIcon};
|
||||
use project::{
|
||||
agent_server_store::{AgentServerStore, ExternalAgentServerName, ExternalAgentSource},
|
||||
agent_server_store::{AgentId, AgentServerStore, ExternalAgentSource},
|
||||
context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
|
||||
};
|
||||
use settings::{Settings, SettingsStore, update_settings_file};
|
||||
@@ -1103,7 +1103,7 @@ impl AgentConfiguration {
|
||||
ExternalAgentSource::Custom => None,
|
||||
};
|
||||
|
||||
let agent_server_name = ExternalAgentServerName(id.clone());
|
||||
let agent_server_name = AgentId(id.clone());
|
||||
|
||||
let uninstall_button = match source {
|
||||
ExternalAgentSource::Extension => Some(
|
||||
|
||||
@@ -10,7 +10,6 @@ use project::{AgentServerStore, AgentServersUpdated, Project};
|
||||
use watch::Receiver;
|
||||
|
||||
use crate::{Agent, ThreadHistory};
|
||||
use project::ExternalAgentServerName;
|
||||
|
||||
pub enum AgentConnectionEntry {
|
||||
Connecting {
|
||||
@@ -143,9 +142,7 @@ impl AgentConnectionStore {
|
||||
let store = store.read(cx);
|
||||
self.entries.retain(|key, _| match key {
|
||||
Agent::NativeAgent => true,
|
||||
Agent::Custom { name } => store
|
||||
.external_agents
|
||||
.contains_key(&ExternalAgentServerName(name.clone())),
|
||||
Agent::Custom { id } => store.external_agents.contains_key(id),
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -1805,7 +1805,7 @@ mod tests {
|
||||
use settings::SettingsStore;
|
||||
use std::{path::Path, rc::Rc};
|
||||
use util::path;
|
||||
use workspace::MultiWorkspace;
|
||||
use workspace::{MultiWorkspace, PathList};
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_multibuffer_agent_diff(cx: &mut TestAppContext) {
|
||||
@@ -1833,9 +1833,11 @@ mod tests {
|
||||
let connection = Rc::new(acp_thread::StubAgentConnection::new());
|
||||
let thread = cx
|
||||
.update(|cx| {
|
||||
connection
|
||||
.clone()
|
||||
.new_session(project.clone(), Path::new(path!("/test")), cx)
|
||||
connection.clone().new_session(
|
||||
project.clone(),
|
||||
PathList::new(&[Path::new(path!("/test"))]),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -2024,9 +2026,11 @@ mod tests {
|
||||
let connection = Rc::new(acp_thread::StubAgentConnection::new());
|
||||
let thread = cx
|
||||
.update(|_, cx| {
|
||||
connection
|
||||
.clone()
|
||||
.new_session(project.clone(), Path::new(path!("/test")), cx)
|
||||
connection.clone().new_session(
|
||||
project.clone(),
|
||||
PathList::new(&[Path::new(path!("/test"))]),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -17,8 +17,8 @@ use collections::HashSet;
|
||||
use db::kvp::{Dismissable, KEY_VALUE_STORE};
|
||||
use itertools::Itertools;
|
||||
use project::{
|
||||
ExternalAgentServerName,
|
||||
agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME},
|
||||
AgentId,
|
||||
agent_server_store::{CLAUDE_AGENT_ID, CODEX_ID, GEMINI_ID},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{LanguageModelProviderSetting, LanguageModelSelection};
|
||||
@@ -86,8 +86,8 @@ use ui::{
|
||||
use util::{ResultExt as _, debug_panic};
|
||||
use workspace::{
|
||||
CollaboratorId, DraggedSelection, DraggedSidebar, DraggedTab, FocusWorkspaceSidebar,
|
||||
MultiWorkspace, OpenResult, SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, ToggleZoom,
|
||||
ToolbarItemView, Workspace, WorkspaceId,
|
||||
MultiWorkspace, OpenResult, PathList, SIDEBAR_RESIZE_HANDLE_SIZE, SerializedPathList,
|
||||
ToggleWorkspaceSidebar, ToggleZoom, ToolbarItemView, Workspace, WorkspaceId,
|
||||
dock::{DockPosition, Panel, PanelEvent},
|
||||
multi_workspace_enabled,
|
||||
};
|
||||
@@ -180,7 +180,7 @@ fn read_legacy_serialized_panel() -> Option<SerializedAgentPanel> {
|
||||
.and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
struct SerializedAgentPanel {
|
||||
width: Option<Pixels>,
|
||||
selected_agent: Option<AgentType>,
|
||||
@@ -190,12 +190,12 @@ struct SerializedAgentPanel {
|
||||
start_thread_in: Option<StartThreadIn>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
struct SerializedActiveThread {
|
||||
session_id: String,
|
||||
agent_type: AgentType,
|
||||
title: Option<String>,
|
||||
cwd: Option<std::path::PathBuf>,
|
||||
work_dirs: Option<SerializedPathList>,
|
||||
}
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
@@ -651,7 +651,8 @@ pub enum AgentType {
|
||||
NativeAgent,
|
||||
TextThread,
|
||||
Custom {
|
||||
name: SharedString,
|
||||
#[serde(rename = "name")]
|
||||
id: AgentId,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -671,13 +672,13 @@ impl<'de> Deserialize<'de> for AgentType {
|
||||
"NativeAgent" => Ok(Self::NativeAgent),
|
||||
"TextThread" => Ok(Self::TextThread),
|
||||
"ClaudeAgent" | "ClaudeCode" => Ok(Self::Custom {
|
||||
name: CLAUDE_AGENT_NAME.into(),
|
||||
id: CLAUDE_AGENT_ID.into(),
|
||||
}),
|
||||
"Codex" => Ok(Self::Custom {
|
||||
name: CODEX_NAME.into(),
|
||||
id: CODEX_ID.into(),
|
||||
}),
|
||||
"Gemini" => Ok(Self::Custom {
|
||||
name: GEMINI_NAME.into(),
|
||||
id: GEMINI_ID.into(),
|
||||
}),
|
||||
other => Err(serde::de::Error::unknown_variant(
|
||||
other,
|
||||
@@ -702,7 +703,9 @@ impl<'de> Deserialize<'de> for AgentType {
|
||||
}
|
||||
let fields: CustomFields =
|
||||
serde_json::from_value(inner.clone()).map_err(serde::de::Error::custom)?;
|
||||
return Ok(Self::Custom { name: fields.name });
|
||||
return Ok(Self::Custom {
|
||||
id: AgentId::new(fields.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -720,7 +723,7 @@ impl AgentType {
|
||||
fn label(&self) -> SharedString {
|
||||
match self {
|
||||
Self::NativeAgent | Self::TextThread => "Zed Agent".into(),
|
||||
Self::Custom { name, .. } => name.into(),
|
||||
Self::Custom { id, .. } => id.0.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,7 +738,7 @@ impl AgentType {
|
||||
impl From<Agent> for AgentType {
|
||||
fn from(value: Agent) -> Self {
|
||||
match value {
|
||||
Agent::Custom { name } => Self::Custom { name },
|
||||
Agent::Custom { id } => Self::Custom { id },
|
||||
Agent::NativeAgent => Self::NativeAgent,
|
||||
}
|
||||
}
|
||||
@@ -913,6 +916,7 @@ impl AgentPanel {
|
||||
let last_active_thread = self.active_agent_thread(cx).map(|thread| {
|
||||
let thread = thread.read(cx);
|
||||
let title = thread.title();
|
||||
let work_dirs = thread.work_dirs().cloned();
|
||||
SerializedActiveThread {
|
||||
session_id: thread.session_id().0.to_string(),
|
||||
agent_type: self.selected_agent_type.clone(),
|
||||
@@ -921,7 +925,7 @@ impl AgentPanel {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
cwd: None,
|
||||
work_dirs: work_dirs.map(|dirs| dirs.serialize()),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -979,7 +983,7 @@ impl AgentPanel {
|
||||
|
||||
let last_active_thread = if let Some(thread_info) = serialized_panel
|
||||
.as_ref()
|
||||
.and_then(|p| p.last_active_thread.clone())
|
||||
.and_then(|p| p.last_active_thread.as_ref())
|
||||
{
|
||||
if thread_info.agent_type.is_native() {
|
||||
let session_id = acp::SessionId::new(thread_info.session_id.clone());
|
||||
@@ -1048,9 +1052,9 @@ impl AgentPanel {
|
||||
if let Some(agent) = panel.selected_agent() {
|
||||
panel.load_agent_thread(
|
||||
agent,
|
||||
thread_info.session_id.into(),
|
||||
thread_info.cwd,
|
||||
thread_info.title.map(SharedString::from),
|
||||
thread_info.session_id.clone().into(),
|
||||
thread_info.work_dirs.as_ref().map(|dirs| PathList::deserialize(dirs)),
|
||||
thread_info.title.as_ref().map(|t| t.clone().into()),
|
||||
false,
|
||||
window,
|
||||
cx,
|
||||
@@ -1292,7 +1296,7 @@ impl AgentPanel {
|
||||
pub fn open_thread(
|
||||
&mut self,
|
||||
session_id: acp::SessionId,
|
||||
cwd: Option<PathBuf>,
|
||||
work_dirs: Option<PathList>,
|
||||
title: Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
@@ -1300,7 +1304,7 @@ impl AgentPanel {
|
||||
self.external_thread(
|
||||
Some(crate::Agent::NativeAgent),
|
||||
Some(session_id),
|
||||
cwd,
|
||||
work_dirs,
|
||||
title,
|
||||
None,
|
||||
true,
|
||||
@@ -1435,7 +1439,7 @@ impl AgentPanel {
|
||||
&mut self,
|
||||
agent_choice: Option<crate::Agent>,
|
||||
resume_session_id: Option<acp::SessionId>,
|
||||
cwd: Option<PathBuf>,
|
||||
work_dirs: Option<PathList>,
|
||||
title: Option<SharedString>,
|
||||
initial_content: Option<AgentInitialContent>,
|
||||
focus: bool,
|
||||
@@ -1476,7 +1480,7 @@ impl AgentPanel {
|
||||
self.create_agent_thread(
|
||||
server,
|
||||
resume_session_id,
|
||||
cwd,
|
||||
work_dirs,
|
||||
title,
|
||||
initial_content,
|
||||
workspace,
|
||||
@@ -1509,7 +1513,7 @@ impl AgentPanel {
|
||||
agent_panel.create_agent_thread(
|
||||
server,
|
||||
resume_session_id,
|
||||
cwd,
|
||||
work_dirs,
|
||||
title,
|
||||
initial_content,
|
||||
workspace,
|
||||
@@ -1569,8 +1573,8 @@ impl AgentPanel {
|
||||
fn has_history_for_selected_agent(&self, cx: &App) -> bool {
|
||||
match &self.selected_agent_type {
|
||||
AgentType::TextThread | AgentType::NativeAgent => true,
|
||||
AgentType::Custom { name } => {
|
||||
let agent = Agent::Custom { name: name.clone() };
|
||||
AgentType::Custom { id } => {
|
||||
let agent = Agent::Custom { id: id.clone() };
|
||||
self.connection_store
|
||||
.read(cx)
|
||||
.entry(&agent)
|
||||
@@ -1599,8 +1603,8 @@ impl AgentPanel {
|
||||
view: self.create_thread_history_view(Agent::NativeAgent, history, window, cx),
|
||||
})
|
||||
}
|
||||
AgentType::Custom { name } => {
|
||||
let agent = Agent::Custom { name: name.clone() };
|
||||
AgentType::Custom { id, .. } => {
|
||||
let agent = Agent::Custom { id: id.clone() };
|
||||
let history = self
|
||||
.connection_store
|
||||
.read(cx)
|
||||
@@ -1635,7 +1639,7 @@ impl AgentPanel {
|
||||
this.load_agent_thread(
|
||||
agent.clone(),
|
||||
thread.session_id.clone(),
|
||||
thread.cwd.clone(),
|
||||
thread.work_dirs.clone(),
|
||||
thread.title.clone(),
|
||||
true,
|
||||
window,
|
||||
@@ -2286,7 +2290,7 @@ impl AgentPanel {
|
||||
this.load_agent_thread(
|
||||
agent,
|
||||
entry.session_id.clone(),
|
||||
entry.cwd.clone(),
|
||||
entry.work_dirs.clone(),
|
||||
entry.title.clone(),
|
||||
true,
|
||||
window,
|
||||
@@ -2415,7 +2419,7 @@ impl AgentPanel {
|
||||
pub(crate) fn selected_agent(&self) -> Option<Agent> {
|
||||
match &self.selected_agent_type {
|
||||
AgentType::NativeAgent => Some(Agent::NativeAgent),
|
||||
AgentType::Custom { name } => Some(Agent::Custom { name: name.clone() }),
|
||||
AgentType::Custom { id } => Some(Agent::Custom { id: id.clone() }),
|
||||
AgentType::TextThread => None,
|
||||
}
|
||||
}
|
||||
@@ -2494,8 +2498,8 @@ impl AgentPanel {
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
AgentType::Custom { name } => self.external_thread(
|
||||
Some(crate::Agent::Custom { name }),
|
||||
AgentType::Custom { id } => self.external_thread(
|
||||
Some(crate::Agent::Custom { id }),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -2511,7 +2515,7 @@ impl AgentPanel {
|
||||
&mut self,
|
||||
agent: Agent,
|
||||
session_id: acp::SessionId,
|
||||
cwd: Option<PathBuf>,
|
||||
work_dirs: Option<PathList>,
|
||||
title: Option<SharedString>,
|
||||
focus: bool,
|
||||
window: &mut Window,
|
||||
@@ -2550,7 +2554,7 @@ impl AgentPanel {
|
||||
self.external_thread(
|
||||
Some(agent),
|
||||
Some(session_id),
|
||||
cwd,
|
||||
work_dirs,
|
||||
title,
|
||||
None,
|
||||
focus,
|
||||
@@ -2563,7 +2567,7 @@ impl AgentPanel {
|
||||
&mut self,
|
||||
server: Rc<dyn AgentServer>,
|
||||
resume_session_id: Option<acp::SessionId>,
|
||||
cwd: Option<PathBuf>,
|
||||
work_dirs: Option<PathList>,
|
||||
title: Option<SharedString>,
|
||||
initial_content: Option<AgentInitialContent>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
@@ -2592,7 +2596,7 @@ impl AgentPanel {
|
||||
connection_store,
|
||||
ext_agent,
|
||||
resume_session_id,
|
||||
cwd,
|
||||
work_dirs,
|
||||
title,
|
||||
initial_content,
|
||||
workspace.clone(),
|
||||
@@ -3871,12 +3875,12 @@ impl AgentPanel {
|
||||
let docked_right = agent_panel_dock_position(cx) == DockPosition::Right;
|
||||
|
||||
let (selected_agent_custom_icon, selected_agent_label) =
|
||||
if let AgentType::Custom { name, .. } = &self.selected_agent_type {
|
||||
if let AgentType::Custom { id, .. } = &self.selected_agent_type {
|
||||
let store = agent_server_store.read(cx);
|
||||
let icon = store.agent_icon(&ExternalAgentServerName(name.clone()));
|
||||
let icon = store.agent_icon(&id);
|
||||
|
||||
let label = store
|
||||
.agent_display_name(&ExternalAgentServerName(name.clone()))
|
||||
.agent_display_name(&id)
|
||||
.unwrap_or_else(|| self.selected_agent_type.label());
|
||||
(icon, label)
|
||||
} else {
|
||||
@@ -4005,24 +4009,24 @@ impl AgentPanel {
|
||||
registry_store.as_ref().map(|s| s.read(cx));
|
||||
|
||||
struct AgentMenuItem {
|
||||
id: ExternalAgentServerName,
|
||||
id: AgentId,
|
||||
display_name: SharedString,
|
||||
}
|
||||
|
||||
let agent_items = agent_server_store
|
||||
.external_agents()
|
||||
.map(|name| {
|
||||
.map(|agent_id| {
|
||||
let display_name = agent_server_store
|
||||
.agent_display_name(name)
|
||||
.agent_display_name(agent_id)
|
||||
.or_else(|| {
|
||||
registry_store_ref
|
||||
.as_ref()
|
||||
.and_then(|store| store.agent(name.0.as_ref()))
|
||||
.and_then(|store| store.agent(agent_id))
|
||||
.map(|a| a.name().clone())
|
||||
})
|
||||
.unwrap_or_else(|| name.0.clone());
|
||||
.unwrap_or_else(|| agent_id.0.clone());
|
||||
AgentMenuItem {
|
||||
id: name.clone(),
|
||||
id: agent_id.clone(),
|
||||
display_name,
|
||||
}
|
||||
})
|
||||
@@ -4038,7 +4042,7 @@ impl AgentPanel {
|
||||
.or_else(|| {
|
||||
registry_store_ref
|
||||
.as_ref()
|
||||
.and_then(|store| store.agent(item.id.0.as_str()))
|
||||
.and_then(|store| store.agent(&item.id))
|
||||
.and_then(|a| a.icon_path().cloned())
|
||||
});
|
||||
|
||||
@@ -4051,7 +4055,7 @@ impl AgentPanel {
|
||||
entry = entry
|
||||
.when(
|
||||
is_agent_selected(AgentType::Custom {
|
||||
name: item.id.0.clone(),
|
||||
id: item.id.clone(),
|
||||
}),
|
||||
|this| {
|
||||
this.action(Box::new(
|
||||
@@ -4073,7 +4077,7 @@ impl AgentPanel {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.new_agent_thread(
|
||||
AgentType::Custom {
|
||||
name: agent_id.0.clone(),
|
||||
id: agent_id.clone(),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
@@ -4098,20 +4102,20 @@ impl AgentPanel {
|
||||
let registry_store_ref =
|
||||
registry_store.as_ref().map(|s| s.read(cx));
|
||||
|
||||
let previous_built_in_ids: &[ExternalAgentServerName] =
|
||||
&[CLAUDE_AGENT_NAME.into(), CODEX_NAME.into(), GEMINI_NAME.into()];
|
||||
let previous_built_in_ids: &[AgentId] =
|
||||
&[CLAUDE_AGENT_ID.into(), CODEX_ID.into(), GEMINI_ID.into()];
|
||||
|
||||
let promoted_items = previous_built_in_ids
|
||||
.iter()
|
||||
.filter(|id| {
|
||||
!agent_server_store.external_agents.contains_key(*id)
|
||||
})
|
||||
.filter_map(|name| {
|
||||
.filter_map(|id| {
|
||||
let display_name = registry_store_ref
|
||||
.as_ref()
|
||||
.and_then(|store| store.agent(name.0.as_ref()))
|
||||
.and_then(|store| store.agent(&id))
|
||||
.map(|a| a.name().clone())?;
|
||||
Some((name.clone(), display_name))
|
||||
Some((id.clone(), display_name))
|
||||
})
|
||||
.sorted_unstable_by_key(|(_, display_name)| display_name.to_lowercase())
|
||||
.collect::<Vec<_>>();
|
||||
@@ -4122,7 +4126,7 @@ impl AgentPanel {
|
||||
|
||||
let icon_path = registry_store_ref
|
||||
.as_ref()
|
||||
.and_then(|store| store.agent(agent_id.0.as_str()))
|
||||
.and_then(|store| store.agent(agent_id))
|
||||
.and_then(|a| a.icon_path().cloned());
|
||||
|
||||
if let Some(icon_path) = icon_path {
|
||||
@@ -4169,7 +4173,7 @@ impl AgentPanel {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.new_agent_thread(
|
||||
AgentType::Custom {
|
||||
name: agent_id.0.clone(),
|
||||
id: agent_id.clone(),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
@@ -5217,7 +5221,7 @@ impl AgentPanel {
|
||||
let project = self.project.clone();
|
||||
|
||||
let ext_agent = Agent::Custom {
|
||||
name: server.name(),
|
||||
id: server.agent_id(),
|
||||
};
|
||||
|
||||
self.create_agent_thread(
|
||||
@@ -5379,7 +5383,7 @@ mod tests {
|
||||
panel_b.update(cx, |panel, _cx| {
|
||||
panel.width = Some(px(400.0));
|
||||
panel.selected_agent_type = AgentType::Custom {
|
||||
name: "claude-acp".into(),
|
||||
id: "claude-acp".into(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -5430,7 +5434,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
panel.selected_agent_type,
|
||||
AgentType::Custom {
|
||||
name: "claude-acp".into()
|
||||
id: "claude-acp".into()
|
||||
},
|
||||
"workspace B agent type should be restored"
|
||||
);
|
||||
@@ -6229,25 +6233,25 @@ mod tests {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<AgentType>(r#""ClaudeAgent""#).unwrap(),
|
||||
AgentType::Custom {
|
||||
name: CLAUDE_AGENT_NAME.into(),
|
||||
id: CLAUDE_AGENT_ID.into(),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<AgentType>(r#""ClaudeCode""#).unwrap(),
|
||||
AgentType::Custom {
|
||||
name: CLAUDE_AGENT_NAME.into(),
|
||||
id: CLAUDE_AGENT_ID.into(),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<AgentType>(r#""Codex""#).unwrap(),
|
||||
AgentType::Custom {
|
||||
name: CODEX_NAME.into(),
|
||||
id: CODEX_ID.into(),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<AgentType>(r#""Gemini""#).unwrap(),
|
||||
AgentType::Custom {
|
||||
name: GEMINI_NAME.into(),
|
||||
id: GEMINI_ID.into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -6265,7 +6269,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<AgentType>(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(),
|
||||
AgentType::Custom {
|
||||
name: "my-agent".into(),
|
||||
id: "my-agent".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -6285,14 +6289,14 @@ mod tests {
|
||||
assert_eq!(
|
||||
panel.selected_agent,
|
||||
Some(AgentType::Custom {
|
||||
name: CLAUDE_AGENT_NAME.into(),
|
||||
id: CLAUDE_AGENT_ID.into(),
|
||||
}),
|
||||
);
|
||||
let thread = panel.last_active_thread.unwrap();
|
||||
assert_eq!(
|
||||
thread.agent_type,
|
||||
AgentType::Custom {
|
||||
name: CODEX_NAME.into(),
|
||||
id: CODEX_ID.into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ mod text_thread_editor;
|
||||
mod text_thread_history;
|
||||
mod thread_history;
|
||||
mod thread_history_view;
|
||||
mod thread_metadata_store;
|
||||
mod threads_archive_view;
|
||||
mod ui;
|
||||
|
||||
@@ -55,7 +56,7 @@ use language::{
|
||||
use language_model::{
|
||||
ConfiguredModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry,
|
||||
};
|
||||
use project::DisableAiSettings;
|
||||
use project::{AgentId, DisableAiSettings};
|
||||
use prompt_store::PromptBuilder;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -221,7 +222,10 @@ pub struct NewNativeAgentThreadFromSummary {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Agent {
|
||||
NativeAgent,
|
||||
Custom { name: SharedString },
|
||||
Custom {
|
||||
#[serde(rename = "name")]
|
||||
id: AgentId,
|
||||
},
|
||||
}
|
||||
|
||||
// Custom impl handles legacy variant names from before the built-in agents were moved to
|
||||
@@ -233,7 +237,7 @@ impl<'de> serde::Deserialize<'de> for Agent {
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use project::agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME};
|
||||
use project::agent_server_store::{CLAUDE_AGENT_ID, CODEX_ID, GEMINI_ID};
|
||||
|
||||
let value = serde_json::Value::deserialize(deserializer)?;
|
||||
|
||||
@@ -241,13 +245,13 @@ impl<'de> serde::Deserialize<'de> for Agent {
|
||||
return match s {
|
||||
"native_agent" => Ok(Self::NativeAgent),
|
||||
"claude_code" | "claude_agent" => Ok(Self::Custom {
|
||||
name: CLAUDE_AGENT_NAME.into(),
|
||||
id: CLAUDE_AGENT_ID.into(),
|
||||
}),
|
||||
"codex" => Ok(Self::Custom {
|
||||
name: CODEX_NAME.into(),
|
||||
id: CODEX_ID.into(),
|
||||
}),
|
||||
"gemini" => Ok(Self::Custom {
|
||||
name: GEMINI_NAME.into(),
|
||||
id: GEMINI_ID.into(),
|
||||
}),
|
||||
other => Err(serde::de::Error::unknown_variant(
|
||||
other,
|
||||
@@ -271,7 +275,9 @@ impl<'de> serde::Deserialize<'de> for Agent {
|
||||
}
|
||||
let fields: CustomFields =
|
||||
serde_json::from_value(inner.clone()).map_err(serde::de::Error::custom)?;
|
||||
return Ok(Self::Custom { name: fields.name });
|
||||
return Ok(Self::Custom {
|
||||
id: AgentId::new(fields.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +295,9 @@ impl Agent {
|
||||
) -> Rc<dyn agent_servers::AgentServer> {
|
||||
match self {
|
||||
Self::NativeAgent => Rc::new(agent::NativeAgentServer::new(fs, thread_store)),
|
||||
Self::Custom { name } => Rc::new(agent_servers::CustomAgentServer::new(name.clone())),
|
||||
Self::Custom { id: name } => {
|
||||
Rc::new(agent_servers::CustomAgentServer::new(name.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -378,6 +386,7 @@ pub fn init(
|
||||
agent_panel::init(cx);
|
||||
context_server_configuration::init(language_registry.clone(), fs.clone(), cx);
|
||||
TextThreadEditor::init(cx);
|
||||
thread_metadata_store::init(cx);
|
||||
|
||||
register_slash_commands(cx);
|
||||
inline_assistant::init(fs.clone(), prompt_builder.clone(), cx);
|
||||
@@ -751,24 +760,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_legacy_external_agent_variants() {
|
||||
use project::agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME};
|
||||
use project::agent_server_store::{CLAUDE_AGENT_ID, CODEX_ID, GEMINI_ID};
|
||||
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Agent>(r#""claude_code""#).unwrap(),
|
||||
Agent::Custom {
|
||||
name: CLAUDE_AGENT_NAME.into(),
|
||||
id: CLAUDE_AGENT_ID.into(),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Agent>(r#""codex""#).unwrap(),
|
||||
Agent::Custom {
|
||||
name: CODEX_NAME.into(),
|
||||
id: CODEX_ID.into(),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Agent>(r#""gemini""#).unwrap(),
|
||||
Agent::Custom {
|
||||
name: GEMINI_NAME.into(),
|
||||
id: GEMINI_ID.into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -782,7 +791,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Agent>(r#"{"custom":{"name":"my-agent"}}"#).unwrap(),
|
||||
Agent::Custom {
|
||||
name: "my-agent".into(),
|
||||
id: "my-agent".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,12 +36,12 @@ use gpui::{
|
||||
use language::Buffer;
|
||||
use language_model::LanguageModelRegistry;
|
||||
use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle};
|
||||
use project::{AgentServerStore, ExternalAgentServerName, Project, ProjectEntryId};
|
||||
use project::{AgentId, AgentServerStore, Project, ProjectEntryId};
|
||||
use prompt_store::{PromptId, PromptStore};
|
||||
use rope::Point;
|
||||
use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore};
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use std::{collections::BTreeMap, rc::Rc, time::Duration};
|
||||
@@ -56,6 +56,7 @@ use ui::{
|
||||
};
|
||||
use util::{ResultExt, size::format_file_size, time::duration_alt_display};
|
||||
use util::{debug_panic, defer};
|
||||
use workspace::PathList;
|
||||
use workspace::{
|
||||
CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId,
|
||||
};
|
||||
@@ -74,6 +75,7 @@ use crate::agent_diff::AgentDiff;
|
||||
use crate::entry_view_state::{EntryViewEvent, ViewEvent};
|
||||
use crate::message_editor::{MessageEditor, MessageEditorEvent};
|
||||
use crate::profile_selector::{ProfileProvider, ProfileSelector};
|
||||
use crate::thread_metadata_store::ThreadMetadataStore;
|
||||
use crate::ui::{AgentNotification, AgentNotificationEvent};
|
||||
use crate::{
|
||||
Agent, AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce,
|
||||
@@ -482,7 +484,7 @@ impl ConnectionView {
|
||||
connection_store: Entity<AgentConnectionStore>,
|
||||
connection_key: Agent,
|
||||
resume_session_id: Option<acp::SessionId>,
|
||||
cwd: Option<PathBuf>,
|
||||
work_dirs: Option<PathList>,
|
||||
title: Option<SharedString>,
|
||||
initial_content: Option<AgentInitialContent>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
@@ -531,7 +533,7 @@ impl ConnectionView {
|
||||
connection_store,
|
||||
connection_key,
|
||||
resume_session_id,
|
||||
cwd,
|
||||
work_dirs,
|
||||
title,
|
||||
project,
|
||||
initial_content,
|
||||
@@ -563,7 +565,7 @@ impl ConnectionView {
|
||||
let thread = thread_view.read(cx).thread.read(cx);
|
||||
(
|
||||
Some(thread.session_id().clone()),
|
||||
thread.cwd().cloned(),
|
||||
thread.work_dirs().cloned(),
|
||||
Some(thread.title()),
|
||||
)
|
||||
})
|
||||
@@ -602,7 +604,7 @@ impl ConnectionView {
|
||||
connection_store: Entity<AgentConnectionStore>,
|
||||
connection_key: Agent,
|
||||
resume_session_id: Option<acp::SessionId>,
|
||||
cwd: Option<PathBuf>,
|
||||
work_dirs: Option<PathList>,
|
||||
title: Option<SharedString>,
|
||||
project: Entity<Project>,
|
||||
initial_content: Option<AgentInitialContent>,
|
||||
@@ -638,24 +640,13 @@ impl ConnectionView {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let session_cwd = cwd
|
||||
.filter(|cwd| {
|
||||
// Validate with the normalized path (rejects `..` traversals),
|
||||
// but return the original cwd to preserve its path separators.
|
||||
// On Windows, `normalize_lexically` rebuilds the path with
|
||||
// backslashes via `PathBuf::push`, which would corrupt
|
||||
// forward-slash Linux paths used by WSL agents.
|
||||
util::paths::normalize_lexically(cwd)
|
||||
.ok()
|
||||
.is_some_and(|normalized| {
|
||||
worktree_roots
|
||||
.iter()
|
||||
.any(|root| normalized.starts_with(root.as_ref()))
|
||||
})
|
||||
})
|
||||
.map(|path| path.into())
|
||||
.or_else(|| worktree_roots.first().cloned())
|
||||
.unwrap_or_else(|| paths::home_dir().as_path().into());
|
||||
let session_work_dirs = work_dirs.unwrap_or_else(|| {
|
||||
if worktree_roots.is_empty() {
|
||||
PathList::new(&[paths::home_dir().as_path()])
|
||||
} else {
|
||||
PathList::new(&worktree_roots)
|
||||
}
|
||||
});
|
||||
|
||||
let connection_entry = connection_store.update(cx, |store, cx| {
|
||||
store.request_connection(connection_key, agent.clone(), cx)
|
||||
@@ -701,7 +692,7 @@ impl ConnectionView {
|
||||
connection.clone().load_session(
|
||||
session_id,
|
||||
project.clone(),
|
||||
&session_cwd,
|
||||
session_work_dirs,
|
||||
title,
|
||||
cx,
|
||||
)
|
||||
@@ -710,7 +701,7 @@ impl ConnectionView {
|
||||
connection.clone().resume_session(
|
||||
session_id,
|
||||
project.clone(),
|
||||
&session_cwd,
|
||||
session_work_dirs,
|
||||
title,
|
||||
cx,
|
||||
)
|
||||
@@ -725,7 +716,7 @@ impl ConnectionView {
|
||||
cx.update(|_, cx| {
|
||||
connection
|
||||
.clone()
|
||||
.new_session(project.clone(), session_cwd.as_ref(), cx)
|
||||
.new_session(project.clone(), session_work_dirs, cx)
|
||||
})
|
||||
.log_err()
|
||||
};
|
||||
@@ -741,7 +732,7 @@ impl ConnectionView {
|
||||
Self::handle_auth_required(
|
||||
this,
|
||||
err,
|
||||
agent.name(),
|
||||
agent.agent_id(),
|
||||
connection,
|
||||
window,
|
||||
cx,
|
||||
@@ -829,7 +820,7 @@ impl ConnectionView {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Entity<ThreadView> {
|
||||
let agent_name = self.agent.name();
|
||||
let agent_id = self.agent.agent_id();
|
||||
let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
|
||||
let available_commands = Rc::new(RefCell::new(vec![]));
|
||||
|
||||
@@ -846,7 +837,7 @@ impl ConnectionView {
|
||||
self.prompt_store.clone(),
|
||||
prompt_capabilities.clone(),
|
||||
available_commands.clone(),
|
||||
self.agent.name(),
|
||||
self.agent.agent_id(),
|
||||
)
|
||||
});
|
||||
|
||||
@@ -969,19 +960,19 @@ impl ConnectionView {
|
||||
let agent_display_name = self
|
||||
.agent_server_store
|
||||
.read(cx)
|
||||
.agent_display_name(&ExternalAgentServerName(agent_name.clone()))
|
||||
.unwrap_or_else(|| agent_name.clone());
|
||||
.agent_display_name(&agent_id.clone())
|
||||
.unwrap_or_else(|| agent_id.0.clone());
|
||||
|
||||
let agent_icon = self.agent.logo();
|
||||
let agent_icon_from_external_svg = self
|
||||
.agent_server_store
|
||||
.read(cx)
|
||||
.agent_icon(&ExternalAgentServerName(self.agent.name()))
|
||||
.agent_icon(&self.agent.agent_id())
|
||||
.or_else(|| {
|
||||
project::AgentRegistryStore::try_global(cx).and_then(|store| {
|
||||
store
|
||||
.read(cx)
|
||||
.agent(self.agent.name().as_ref())
|
||||
.agent(&self.agent.agent_id())
|
||||
.and_then(|a| a.icon_path().cloned())
|
||||
})
|
||||
});
|
||||
@@ -995,7 +986,7 @@ impl ConnectionView {
|
||||
weak,
|
||||
agent_icon,
|
||||
agent_icon_from_external_svg,
|
||||
agent_name,
|
||||
agent_id,
|
||||
agent_display_name,
|
||||
self.workspace.clone(),
|
||||
entry_view_state,
|
||||
@@ -1022,7 +1013,7 @@ impl ConnectionView {
|
||||
fn handle_auth_required(
|
||||
this: WeakEntity<Self>,
|
||||
err: AuthRequired,
|
||||
agent_name: SharedString,
|
||||
agent_id: AgentId,
|
||||
connection: Rc<dyn AgentConnection>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
@@ -1051,7 +1042,7 @@ impl ConnectionView {
|
||||
|
||||
let view = registry.read(cx).provider(&provider_id).map(|provider| {
|
||||
provider.configuration_view(
|
||||
language_model::ConfigurationViewTargetAgent::Other(agent_name),
|
||||
language_model::ConfigurationViewTargetAgent::Other(agent_id.0),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -1166,12 +1157,14 @@ impl ConnectionView {
|
||||
ServerState::Connected(_) => "New Thread".into(),
|
||||
ServerState::Loading(_) => "Loading…".into(),
|
||||
ServerState::LoadError { error, .. } => match error {
|
||||
LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(),
|
||||
LoadError::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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ use collections::HashMap;
|
||||
use editor::{Editor, EditorEvent, EditorMode, MinimapVisibility, SizingBehavior};
|
||||
use gpui::{
|
||||
AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
|
||||
ScrollHandle, SharedString, TextStyleRefinement, WeakEntity, Window,
|
||||
ScrollHandle, TextStyleRefinement, WeakEntity, Window,
|
||||
};
|
||||
use language::language_settings::SoftWrap;
|
||||
use project::Project;
|
||||
use project::{AgentId, Project};
|
||||
use prompt_store::PromptStore;
|
||||
use rope::Point;
|
||||
use settings::Settings as _;
|
||||
@@ -31,7 +31,7 @@ pub struct EntryViewState {
|
||||
entries: Vec<Entry>,
|
||||
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
|
||||
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
|
||||
agent_name: SharedString,
|
||||
agent_id: AgentId,
|
||||
}
|
||||
|
||||
impl EntryViewState {
|
||||
@@ -43,7 +43,7 @@ impl EntryViewState {
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
|
||||
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
|
||||
agent_name: SharedString,
|
||||
agent_id: AgentId,
|
||||
) -> Self {
|
||||
Self {
|
||||
workspace,
|
||||
@@ -54,7 +54,7 @@ impl EntryViewState {
|
||||
entries: Vec::new(),
|
||||
prompt_capabilities,
|
||||
available_commands,
|
||||
agent_name,
|
||||
agent_id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ impl EntryViewState {
|
||||
self.prompt_store.clone(),
|
||||
self.prompt_capabilities.clone(),
|
||||
self.available_commands.clone(),
|
||||
self.agent_name.clone(),
|
||||
self.agent_id.clone(),
|
||||
"Edit message - @ to include context",
|
||||
editor::EditorMode::AutoHeight {
|
||||
min_lines: 1,
|
||||
@@ -468,7 +468,7 @@ mod tests {
|
||||
use serde_json::json;
|
||||
use settings::SettingsStore;
|
||||
use util::path;
|
||||
use workspace::MultiWorkspace;
|
||||
use workspace::{MultiWorkspace, PathList};
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_diff_sync(cx: &mut TestAppContext) {
|
||||
@@ -495,9 +495,11 @@ mod tests {
|
||||
let connection = Rc::new(StubAgentConnection::new());
|
||||
let thread = cx
|
||||
.update(|_, cx| {
|
||||
connection
|
||||
.clone()
|
||||
.new_session(project.clone(), Path::new(path!("/project")), cx)
|
||||
connection.clone().new_session(
|
||||
project.clone(),
|
||||
PathList::new(&[Path::new(path!("/project"))]),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -27,6 +27,7 @@ use gpui::{
|
||||
KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity,
|
||||
};
|
||||
use language::{Buffer, Language, language_settings::InlayHintKind};
|
||||
use project::AgentId;
|
||||
use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree};
|
||||
use prompt_store::PromptStore;
|
||||
use rope::Point;
|
||||
@@ -45,7 +46,7 @@ pub struct MessageEditor {
|
||||
workspace: WeakEntity<Workspace>,
|
||||
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
|
||||
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
|
||||
agent_name: SharedString,
|
||||
agent_id: AgentId,
|
||||
thread_store: Option<Entity<ThreadStore>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
_parse_slash_command_task: Task<()>,
|
||||
@@ -113,7 +114,7 @@ impl MessageEditor {
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
|
||||
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
|
||||
agent_name: SharedString,
|
||||
agent_id: AgentId,
|
||||
placeholder: &str,
|
||||
mode: EditorMode,
|
||||
window: &mut Window,
|
||||
@@ -236,7 +237,7 @@ impl MessageEditor {
|
||||
workspace,
|
||||
prompt_capabilities,
|
||||
available_commands,
|
||||
agent_name,
|
||||
agent_id,
|
||||
thread_store,
|
||||
_subscriptions: subscriptions,
|
||||
_parse_slash_command_task: Task::ready(()),
|
||||
@@ -379,7 +380,7 @@ impl MessageEditor {
|
||||
fn validate_slash_commands(
|
||||
text: &str,
|
||||
available_commands: &[acp::AvailableCommand],
|
||||
agent_name: &str,
|
||||
agent_id: &AgentId,
|
||||
) -> Result<()> {
|
||||
if let Some(parsed_command) = SlashCommandCompletion::try_parse(text, 0) {
|
||||
if let Some(command_name) = parsed_command.command {
|
||||
@@ -392,7 +393,7 @@ impl MessageEditor {
|
||||
return Err(anyhow!(
|
||||
"The /{} command is not supported by {}.\n\nAvailable commands: {}",
|
||||
command_name,
|
||||
agent_name,
|
||||
agent_id,
|
||||
if available_commands.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
@@ -416,11 +417,11 @@ impl MessageEditor {
|
||||
) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
|
||||
let text = self.editor.read(cx).text(cx);
|
||||
let available_commands = self.available_commands.borrow().clone();
|
||||
let agent_name = self.agent_name.clone();
|
||||
let agent_id = self.agent_id.clone();
|
||||
let build_task = self.build_content_blocks(full_mention_content, cx);
|
||||
|
||||
cx.spawn(async move |_, _cx| {
|
||||
Self::validate_slash_commands(&text, &available_commands, &agent_name)?;
|
||||
Self::validate_slash_commands(&text, &available_commands, &agent_id)?;
|
||||
build_task.await
|
||||
})
|
||||
}
|
||||
|
||||
+375
-399
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,8 @@
|
||||
use acp_thread::{AgentConnection, StubAgentConnection};
|
||||
use agent_client_protocol as acp;
|
||||
use agent_servers::{AgentServer, AgentServerDelegate};
|
||||
use gpui::{Entity, SharedString, Task, TestAppContext, VisualTestContext};
|
||||
use gpui::{Entity, Task, TestAppContext, VisualTestContext};
|
||||
use project::AgentId;
|
||||
use settings::SettingsStore;
|
||||
use std::any::Any;
|
||||
use std::rc::Rc;
|
||||
@@ -37,7 +38,7 @@ where
|
||||
ui::IconName::Ai
|
||||
}
|
||||
|
||||
fn name(&self) -> SharedString {
|
||||
fn agent_id(&self) -> AgentId {
|
||||
"Test".into()
|
||||
}
|
||||
|
||||
|
||||
@@ -408,7 +408,7 @@ mod tests {
|
||||
fn test_session(session_id: &str, title: &str) -> AgentSessionInfo {
|
||||
AgentSessionInfo {
|
||||
session_id: acp::SessionId::new(session_id),
|
||||
cwd: None,
|
||||
work_dirs: None,
|
||||
title: Some(title.to_string().into()),
|
||||
updated_at: None,
|
||||
created_at: None,
|
||||
@@ -608,7 +608,7 @@ mod tests {
|
||||
let session_id = acp::SessionId::new("test-session");
|
||||
let sessions = vec![AgentSessionInfo {
|
||||
session_id: session_id.clone(),
|
||||
cwd: None,
|
||||
work_dirs: None,
|
||||
title: Some("Original Title".into()),
|
||||
updated_at: None,
|
||||
created_at: None,
|
||||
@@ -641,7 +641,7 @@ mod tests {
|
||||
let session_id = acp::SessionId::new("test-session");
|
||||
let sessions = vec![AgentSessionInfo {
|
||||
session_id: session_id.clone(),
|
||||
cwd: None,
|
||||
work_dirs: None,
|
||||
title: Some("Original Title".into()),
|
||||
updated_at: None,
|
||||
created_at: None,
|
||||
@@ -671,7 +671,7 @@ mod tests {
|
||||
let session_id = acp::SessionId::new("test-session");
|
||||
let sessions = vec![AgentSessionInfo {
|
||||
session_id: session_id.clone(),
|
||||
cwd: None,
|
||||
work_dirs: None,
|
||||
title: Some("Original Title".into()),
|
||||
updated_at: None,
|
||||
created_at: None,
|
||||
@@ -704,7 +704,7 @@ mod tests {
|
||||
let session_id = acp::SessionId::new("test-session");
|
||||
let sessions = vec![AgentSessionInfo {
|
||||
session_id: session_id.clone(),
|
||||
cwd: None,
|
||||
work_dirs: None,
|
||||
title: None,
|
||||
updated_at: None,
|
||||
created_at: None,
|
||||
@@ -741,7 +741,7 @@ mod tests {
|
||||
let session_id = acp::SessionId::new("test-session");
|
||||
let sessions = vec![AgentSessionInfo {
|
||||
session_id: session_id.clone(),
|
||||
cwd: None,
|
||||
work_dirs: None,
|
||||
title: Some("Server Title".into()),
|
||||
updated_at: None,
|
||||
created_at: None,
|
||||
@@ -775,7 +775,7 @@ mod tests {
|
||||
let session_id = acp::SessionId::new("known-session");
|
||||
let sessions = vec![AgentSessionInfo {
|
||||
session_id,
|
||||
cwd: None,
|
||||
work_dirs: None,
|
||||
title: Some("Original".into()),
|
||||
updated_at: None,
|
||||
created_at: None,
|
||||
|
||||
@@ -755,7 +755,7 @@ impl RenderOnce for HistoryEntryElement {
|
||||
panel.load_agent_thread(
|
||||
agent,
|
||||
entry.session_id.clone(),
|
||||
entry.cwd.clone(),
|
||||
entry.work_dirs.clone(),
|
||||
entry.title.clone(),
|
||||
true,
|
||||
window,
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
use agent::{ThreadStore, ZED_AGENT_ID};
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use collections::HashMap;
|
||||
use db::{
|
||||
sqlez::{
|
||||
bindable::Column, domain::Domain, statement::Statement,
|
||||
thread_safe_connection::ThreadSafeConnection,
|
||||
},
|
||||
sqlez_macros::sql,
|
||||
};
|
||||
use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
|
||||
use gpui::{AppContext as _, Entity, Global, Subscription, Task};
|
||||
use project::AgentId;
|
||||
use ui::{App, Context, SharedString};
|
||||
use workspace::PathList;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
ThreadMetadataStore::init_global(cx);
|
||||
|
||||
if cx.has_flag::<AgentV2FeatureFlag>() {
|
||||
migrate_thread_metadata(cx);
|
||||
}
|
||||
cx.observe_flag::<AgentV2FeatureFlag, _>(|has_flag, cx| {
|
||||
if has_flag {
|
||||
migrate_thread_metadata(cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Migrate existing thread metadata from native agent thread store to the new metadata storage.
|
||||
///
|
||||
/// TODO: Remove this after N weeks of shipping the sidebar
|
||||
fn migrate_thread_metadata(cx: &mut App) {
|
||||
ThreadMetadataStore::global(cx).update(cx, |store, cx| {
|
||||
let list = store.list(cx);
|
||||
cx.spawn(async move |this, cx| {
|
||||
let Ok(list) = list.await else {
|
||||
return;
|
||||
};
|
||||
if list.is_empty() {
|
||||
this.update(cx, |this, cx| {
|
||||
let metadata = ThreadStore::global(cx)
|
||||
.read(cx)
|
||||
.entries()
|
||||
.map(|entry| ThreadMetadata {
|
||||
session_id: entry.id,
|
||||
agent_id: None,
|
||||
title: entry.title,
|
||||
updated_at: entry.updated_at,
|
||||
created_at: entry.created_at,
|
||||
folder_paths: entry.folder_paths,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for entry in metadata {
|
||||
this.save(entry, cx).detach_and_log_err(cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
}
|
||||
|
||||
struct GlobalThreadMetadataStore(Entity<ThreadMetadataStore>);
|
||||
impl Global for GlobalThreadMetadataStore {}
|
||||
|
||||
/// Lightweight metadata for any thread (native or ACP), enough to populate
|
||||
/// the sidebar list and route to the correct load path when clicked.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThreadMetadata {
|
||||
pub session_id: acp::SessionId,
|
||||
/// `None` for native Zed threads, `Some("claude-code")` etc. for ACP agents.
|
||||
pub agent_id: Option<AgentId>,
|
||||
pub title: SharedString,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub folder_paths: PathList,
|
||||
}
|
||||
|
||||
pub struct ThreadMetadataStore {
|
||||
db: ThreadMetadataDb,
|
||||
session_subscriptions: HashMap<acp::SessionId, Subscription>,
|
||||
}
|
||||
|
||||
impl ThreadMetadataStore {
|
||||
#[cfg(not(any(test, feature = "test-support")))]
|
||||
pub fn init_global(cx: &mut App) {
|
||||
if cx.has_global::<Self>() {
|
||||
return;
|
||||
}
|
||||
|
||||
let db = THREAD_METADATA_DB.clone();
|
||||
let thread_store = cx.new(|cx| Self::new(db, cx));
|
||||
cx.set_global(GlobalThreadMetadataStore(thread_store));
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn init_global(cx: &mut App) {
|
||||
let thread = std::thread::current();
|
||||
let test_name = thread.name().unwrap_or("unknown_test");
|
||||
let db_name = format!("THREAD_METADATA_DB_{}", test_name);
|
||||
let db = smol::block_on(db::open_test_db::<ThreadMetadataDb>(&db_name));
|
||||
let thread_store = cx.new(|cx| Self::new(ThreadMetadataDb(db), cx));
|
||||
cx.set_global(GlobalThreadMetadataStore(thread_store));
|
||||
}
|
||||
|
||||
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
|
||||
cx.try_global::<GlobalThreadMetadataStore>()
|
||||
.map(|store| store.0.clone())
|
||||
}
|
||||
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalThreadMetadataStore>().0.clone()
|
||||
}
|
||||
|
||||
pub fn list(&self, cx: &App) -> Task<Result<Vec<ThreadMetadata>>> {
|
||||
let db = self.db.clone();
|
||||
cx.background_spawn(async move {
|
||||
let s = db.list()?;
|
||||
Ok(s)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
if !cx.has_flag::<AgentV2FeatureFlag>() {
|
||||
return Task::ready(Ok(()));
|
||||
}
|
||||
|
||||
let db = self.db.clone();
|
||||
cx.spawn(async move |this, cx| {
|
||||
db.save(metadata).await?;
|
||||
this.update(cx, |_this, cx| cx.notify())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete(
|
||||
&mut self,
|
||||
session_id: acp::SessionId,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
if !cx.has_flag::<AgentV2FeatureFlag>() {
|
||||
return Task::ready(Ok(()));
|
||||
}
|
||||
|
||||
let db = self.db.clone();
|
||||
cx.spawn(async move |this, cx| {
|
||||
db.delete(session_id).await?;
|
||||
this.update(cx, |_this, cx| cx.notify())
|
||||
})
|
||||
}
|
||||
|
||||
fn new(db: ThreadMetadataDb, cx: &mut Context<Self>) -> Self {
|
||||
let weak_store = cx.weak_entity();
|
||||
|
||||
cx.observe_new::<acp_thread::AcpThread>(move |thread, _window, cx| {
|
||||
let thread_entity = cx.entity();
|
||||
|
||||
cx.on_release({
|
||||
let weak_store = weak_store.clone();
|
||||
move |thread, cx| {
|
||||
weak_store
|
||||
.update(cx, |store, _cx| {
|
||||
store.session_subscriptions.remove(thread.session_id());
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
weak_store
|
||||
.update(cx, |this, cx| {
|
||||
let subscription = cx.subscribe(&thread_entity, Self::handle_thread_update);
|
||||
this.session_subscriptions
|
||||
.insert(thread.session_id().clone(), subscription);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
db,
|
||||
session_subscriptions: HashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_thread_update(
|
||||
&mut self,
|
||||
thread: Entity<acp_thread::AcpThread>,
|
||||
event: &acp_thread::AcpThreadEvent,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match event {
|
||||
acp_thread::AcpThreadEvent::NewEntry
|
||||
| acp_thread::AcpThreadEvent::EntryUpdated(_)
|
||||
| acp_thread::AcpThreadEvent::TitleUpdated => {
|
||||
let metadata = Self::metadata_for_acp_thread(thread.read(cx), cx);
|
||||
self.save(metadata, cx).detach_and_log_err(cx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_for_acp_thread(thread: &acp_thread::AcpThread, cx: &App) -> ThreadMetadata {
|
||||
let session_id = thread.session_id().clone();
|
||||
let title = thread.title();
|
||||
let updated_at = Utc::now();
|
||||
|
||||
let agent_id = thread.connection().agent_id();
|
||||
|
||||
let agent_id = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() {
|
||||
None
|
||||
} else {
|
||||
Some(agent_id)
|
||||
};
|
||||
|
||||
let folder_paths = {
|
||||
let project = thread.project().read(cx);
|
||||
let paths: Vec<Arc<Path>> = project
|
||||
.visible_worktrees(cx)
|
||||
.map(|worktree| worktree.read(cx).abs_path())
|
||||
.collect();
|
||||
PathList::new(&paths)
|
||||
};
|
||||
|
||||
ThreadMetadata {
|
||||
session_id,
|
||||
agent_id,
|
||||
title,
|
||||
created_at: Some(updated_at), // handled by db `ON CONFLICT`
|
||||
updated_at,
|
||||
folder_paths,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Global for ThreadMetadataStore {}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ThreadMetadataDb(ThreadSafeConnection);
|
||||
|
||||
impl Domain for ThreadMetadataDb {
|
||||
const NAME: &str = stringify!(ThreadMetadataDb);
|
||||
|
||||
const MIGRATIONS: &[&str] = &[sql!(
|
||||
CREATE TABLE IF NOT EXISTS sidebar_threads(
|
||||
session_id TEXT PRIMARY KEY,
|
||||
agent_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
created_at TEXT,
|
||||
folder_paths TEXT,
|
||||
folder_paths_order TEXT
|
||||
) STRICT;
|
||||
)];
|
||||
}
|
||||
|
||||
db::static_connection!(THREAD_METADATA_DB, ThreadMetadataDb, []);
|
||||
|
||||
impl ThreadMetadataDb {
|
||||
/// List all sidebar thread metadata, ordered by updated_at descending.
|
||||
pub fn list(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
|
||||
self.select::<ThreadMetadata>(
|
||||
"SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order \
|
||||
FROM sidebar_threads \
|
||||
ORDER BY updated_at DESC"
|
||||
)?()
|
||||
}
|
||||
|
||||
/// Upsert metadata for a thread.
|
||||
pub async fn save(&self, row: ThreadMetadata) -> anyhow::Result<()> {
|
||||
let id = row.session_id.0.clone();
|
||||
let agent_id = row.agent_id.as_ref().map(|id| id.0.to_string());
|
||||
let title = row.title.to_string();
|
||||
let updated_at = row.updated_at.to_rfc3339();
|
||||
let created_at = row.created_at.map(|dt| dt.to_rfc3339());
|
||||
let serialized = row.folder_paths.serialize();
|
||||
let (folder_paths, folder_paths_order) = if row.folder_paths.is_empty() {
|
||||
(None, None)
|
||||
} else {
|
||||
(Some(serialized.paths), Some(serialized.order))
|
||||
};
|
||||
|
||||
self.write(move |conn| {
|
||||
let sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
|
||||
ON CONFLICT(session_id) DO UPDATE SET \
|
||||
agent_id = excluded.agent_id, \
|
||||
title = excluded.title, \
|
||||
updated_at = excluded.updated_at, \
|
||||
folder_paths = excluded.folder_paths, \
|
||||
folder_paths_order = excluded.folder_paths_order";
|
||||
let mut stmt = Statement::prepare(conn, sql)?;
|
||||
let mut i = stmt.bind(&id, 1)?;
|
||||
i = stmt.bind(&agent_id, i)?;
|
||||
i = stmt.bind(&title, i)?;
|
||||
i = stmt.bind(&updated_at, i)?;
|
||||
i = stmt.bind(&created_at, i)?;
|
||||
i = stmt.bind(&folder_paths, i)?;
|
||||
stmt.bind(&folder_paths_order, i)?;
|
||||
stmt.exec()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete metadata for a single thread.
|
||||
pub async fn delete(&self, session_id: acp::SessionId) -> anyhow::Result<()> {
|
||||
let id = session_id.0.clone();
|
||||
self.write(move |conn| {
|
||||
let mut stmt =
|
||||
Statement::prepare(conn, "DELETE FROM sidebar_threads WHERE session_id = ?")?;
|
||||
stmt.bind(&id, 1)?;
|
||||
stmt.exec()
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl Column for ThreadMetadata {
|
||||
fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
|
||||
let (id, next): (Arc<str>, i32) = Column::column(statement, start_index)?;
|
||||
let (agent_id, next): (Option<String>, i32) = Column::column(statement, next)?;
|
||||
let (title, next): (String, i32) = Column::column(statement, next)?;
|
||||
let (updated_at_str, next): (String, i32) = Column::column(statement, next)?;
|
||||
let (created_at_str, next): (Option<String>, i32) = Column::column(statement, next)?;
|
||||
let (folder_paths_str, next): (Option<String>, i32) = Column::column(statement, next)?;
|
||||
let (folder_paths_order_str, next): (Option<String>, i32) =
|
||||
Column::column(statement, next)?;
|
||||
|
||||
let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc);
|
||||
let created_at = created_at_str
|
||||
.as_deref()
|
||||
.map(DateTime::parse_from_rfc3339)
|
||||
.transpose()?
|
||||
.map(|dt| dt.with_timezone(&Utc));
|
||||
|
||||
let folder_paths = folder_paths_str
|
||||
.map(|paths| {
|
||||
PathList::deserialize(&util::path_list::SerializedPathList {
|
||||
paths,
|
||||
order: folder_paths_order_str.unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok((
|
||||
ThreadMetadata {
|
||||
session_id: acp::SessionId::new(id),
|
||||
agent_id: agent_id.map(|id| AgentId::new(id)),
|
||||
title: title.into(),
|
||||
updated_at,
|
||||
created_at,
|
||||
folder_paths,
|
||||
},
|
||||
next,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use agent::DbThread;
|
||||
use gpui::TestAppContext;
|
||||
|
||||
fn make_db_thread(title: &str, updated_at: DateTime<Utc>) -> DbThread {
|
||||
DbThread {
|
||||
title: title.to_string().into(),
|
||||
messages: Vec::new(),
|
||||
updated_at,
|
||||
detailed_summary: None,
|
||||
initial_project_snapshot: None,
|
||||
cumulative_token_usage: Default::default(),
|
||||
request_token_usage: Default::default(),
|
||||
model: None,
|
||||
profile: None,
|
||||
imported: false,
|
||||
subagent_context: None,
|
||||
speed: None,
|
||||
thinking_enabled: false,
|
||||
thinking_effort: None,
|
||||
draft_prompt: None,
|
||||
ui_scroll_position: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_migrate_thread_metadata(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
ThreadStore::init_global(cx);
|
||||
ThreadMetadataStore::init_global(cx);
|
||||
});
|
||||
|
||||
// Verify the list is empty before migration
|
||||
let metadata_list = cx.update(|cx| {
|
||||
let store = ThreadMetadataStore::global(cx);
|
||||
store.read(cx).list(cx)
|
||||
});
|
||||
|
||||
let list = metadata_list.await.unwrap();
|
||||
assert_eq!(list.len(), 0);
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
// Populate the native ThreadStore via save_thread
|
||||
let save1 = cx.update(|cx| {
|
||||
let thread_store = ThreadStore::global(cx);
|
||||
thread_store.update(cx, |store, cx| {
|
||||
store.save_thread(
|
||||
acp::SessionId::new("session-1"),
|
||||
make_db_thread("Thread 1", now),
|
||||
PathList::default(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
});
|
||||
save1.await.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
let save2 = cx.update(|cx| {
|
||||
let thread_store = ThreadStore::global(cx);
|
||||
thread_store.update(cx, |store, cx| {
|
||||
store.save_thread(
|
||||
acp::SessionId::new("session-2"),
|
||||
make_db_thread("Thread 2", now),
|
||||
PathList::default(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
});
|
||||
save2.await.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
// Run migration
|
||||
cx.update(|cx| {
|
||||
migrate_thread_metadata(cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
// Verify the metadata was migrated
|
||||
let metadata_list = cx.update(|cx| {
|
||||
let store = ThreadMetadataStore::global(cx);
|
||||
store.read(cx).list(cx)
|
||||
});
|
||||
|
||||
let list = metadata_list.await.unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
|
||||
let metadata1 = list
|
||||
.iter()
|
||||
.find(|m| m.session_id.0.as_ref() == "session-1")
|
||||
.expect("session-1 should be in migrated metadata");
|
||||
assert_eq!(metadata1.title.as_ref(), "Thread 1");
|
||||
assert!(metadata1.agent_id.is_none());
|
||||
|
||||
let metadata2 = list
|
||||
.iter()
|
||||
.find(|m| m.session_id.0.as_ref() == "session-2")
|
||||
.expect("session-2 should be in migrated metadata");
|
||||
assert_eq!(metadata2.title.as_ref(), "Thread 2");
|
||||
assert!(metadata2.agent_id.is_none());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_migrate_thread_metadata_skips_when_data_exists(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
ThreadStore::init_global(cx);
|
||||
ThreadMetadataStore::init_global(cx);
|
||||
});
|
||||
|
||||
// Pre-populate the metadata store with existing data
|
||||
let existing_metadata = ThreadMetadata {
|
||||
session_id: acp::SessionId::new("existing-session"),
|
||||
agent_id: None,
|
||||
title: "Existing Thread".into(),
|
||||
updated_at: Utc::now(),
|
||||
created_at: Some(Utc::now()),
|
||||
folder_paths: PathList::default(),
|
||||
};
|
||||
|
||||
cx.update(|cx| {
|
||||
let store = ThreadMetadataStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.save(existing_metadata, cx).detach();
|
||||
});
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
// Add an entry to native thread store that should NOT be migrated
|
||||
let save_task = cx.update(|cx| {
|
||||
let thread_store = ThreadStore::global(cx);
|
||||
thread_store.update(cx, |store, cx| {
|
||||
store.save_thread(
|
||||
acp::SessionId::new("native-session"),
|
||||
make_db_thread("Native Thread", Utc::now()),
|
||||
PathList::default(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
});
|
||||
save_task.await.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
// Run migration - should skip because metadata store is not empty
|
||||
cx.update(|cx| {
|
||||
migrate_thread_metadata(cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
// Verify only the existing metadata is present (migration was skipped)
|
||||
let metadata_list = cx.update(|cx| {
|
||||
let store = ThreadMetadataStore::global(cx);
|
||||
store.read(cx).list(cx)
|
||||
});
|
||||
|
||||
let list = metadata_list.await.unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].session_id.0.as_ref(), "existing-session");
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use gpui::{
|
||||
};
|
||||
use itertools::Itertools as _;
|
||||
use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
|
||||
use project::{AgentServerStore, ExternalAgentServerName};
|
||||
use project::{AgentId, AgentServerStore};
|
||||
use theme::ActiveTheme;
|
||||
use ui::{
|
||||
ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, HighlightedLabel, ListItem,
|
||||
@@ -530,9 +530,9 @@ impl ThreadsArchiveView {
|
||||
(IconName::ChevronDown, Color::Muted)
|
||||
};
|
||||
|
||||
let selected_agent_icon = if let Agent::Custom { name } = &self.selected_agent {
|
||||
let selected_agent_icon = if let Agent::Custom { id } = &self.selected_agent {
|
||||
let store = agent_server_store.read(cx);
|
||||
let icon = store.agent_icon(&ExternalAgentServerName(name.clone()));
|
||||
let icon = store.agent_icon(&id);
|
||||
|
||||
if let Some(icon) = icon {
|
||||
Icon::from_external_svg(icon)
|
||||
@@ -584,24 +584,24 @@ impl ThreadsArchiveView {
|
||||
let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
|
||||
|
||||
struct AgentMenuItem {
|
||||
id: ExternalAgentServerName,
|
||||
id: AgentId,
|
||||
display_name: SharedString,
|
||||
}
|
||||
|
||||
let agent_items = agent_server_store
|
||||
.external_agents()
|
||||
.map(|name| {
|
||||
.map(|agent_id| {
|
||||
let display_name = agent_server_store
|
||||
.agent_display_name(name)
|
||||
.agent_display_name(agent_id)
|
||||
.or_else(|| {
|
||||
registry_store_ref
|
||||
.as_ref()
|
||||
.and_then(|store| store.agent(name.0.as_ref()))
|
||||
.and_then(|store| store.agent(agent_id))
|
||||
.map(|a| a.name().clone())
|
||||
})
|
||||
.unwrap_or_else(|| name.0.clone());
|
||||
.unwrap_or_else(|| agent_id.0.clone());
|
||||
AgentMenuItem {
|
||||
id: name.clone(),
|
||||
id: agent_id.clone(),
|
||||
display_name,
|
||||
}
|
||||
})
|
||||
@@ -614,7 +614,7 @@ impl ThreadsArchiveView {
|
||||
let icon_path = agent_server_store.agent_icon(&item.id).or_else(|| {
|
||||
registry_store_ref
|
||||
.as_ref()
|
||||
.and_then(|store| store.agent(item.id.0.as_str()))
|
||||
.and_then(|store| store.agent(&item.id))
|
||||
.and_then(|a| a.icon_path().cloned())
|
||||
});
|
||||
|
||||
@@ -627,7 +627,7 @@ impl ThreadsArchiveView {
|
||||
entry = entry.icon_color(Color::Muted).handler({
|
||||
let this = this.clone();
|
||||
let agent = Agent::Custom {
|
||||
name: item.id.0.clone(),
|
||||
id: item.id.clone(),
|
||||
};
|
||||
move |window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
|
||||
@@ -2,7 +2,7 @@ use gpui::{
|
||||
ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render,
|
||||
linear_color_stop, linear_gradient,
|
||||
};
|
||||
use project::agent_server_store::GEMINI_NAME;
|
||||
use project::agent_server_store::GEMINI_ID;
|
||||
use ui::{TintColor, Vector, VectorName, prelude::*};
|
||||
use workspace::{ModalView, Workspace};
|
||||
|
||||
@@ -39,7 +39,7 @@ impl AcpOnboardingModal {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.new_agent_thread(
|
||||
AgentType::Custom {
|
||||
name: GEMINI_NAME.into(),
|
||||
id: GEMINI_ID.into(),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
|
||||
@@ -2,7 +2,7 @@ use gpui::{
|
||||
ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render,
|
||||
linear_color_stop, linear_gradient,
|
||||
};
|
||||
use project::agent_server_store::CLAUDE_AGENT_NAME;
|
||||
use project::agent_server_store::CLAUDE_AGENT_ID;
|
||||
use ui::{TintColor, Vector, VectorName, prelude::*};
|
||||
use workspace::{ModalView, Workspace};
|
||||
|
||||
@@ -39,7 +39,7 @@ impl ClaudeCodeOnboardingModal {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.new_agent_thread(
|
||||
AgentType::Custom {
|
||||
name: CLAUDE_AGENT_NAME.into(),
|
||||
id: CLAUDE_AGENT_ID.into(),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
|
||||
@@ -50,6 +50,7 @@ use gpui::{AppContext as _, AsyncApp, Entity, UpdateGlobal};
|
||||
use language_model::{LanguageModelRegistry, SelectedModel};
|
||||
use project::Project;
|
||||
use settings::SettingsStore;
|
||||
use util::path_list::PathList;
|
||||
|
||||
use crate::headless::AgentCliAppState;
|
||||
|
||||
@@ -370,7 +371,11 @@ async fn run_agent(
|
||||
|
||||
let connection = Rc::new(NativeAgentConnection(agent.clone()));
|
||||
let acp_thread = match cx
|
||||
.update(|cx| connection.clone().new_session(project, workdir, cx))
|
||||
.update(|cx| {
|
||||
connection
|
||||
.clone()
|
||||
.new_session(project, PathList::new(&[workdir]), cx)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
|
||||
@@ -11,14 +11,14 @@ use http_client::{AsyncBody, HttpClient};
|
||||
use serde::Deserialize;
|
||||
use settings::Settings as _;
|
||||
|
||||
use crate::DisableAiSettings;
|
||||
use crate::{AgentId, DisableAiSettings};
|
||||
|
||||
const REGISTRY_URL: &str = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
|
||||
const REFRESH_THROTTLE_DURATION: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RegistryAgentMetadata {
|
||||
pub id: SharedString,
|
||||
pub id: AgentId,
|
||||
pub name: SharedString,
|
||||
pub description: SharedString,
|
||||
pub version: SharedString,
|
||||
@@ -55,7 +55,7 @@ impl RegistryAgent {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &SharedString {
|
||||
pub fn id(&self) -> &AgentId {
|
||||
&self.metadata().id
|
||||
}
|
||||
|
||||
@@ -167,8 +167,8 @@ impl AgentRegistryStore {
|
||||
&self.agents
|
||||
}
|
||||
|
||||
pub fn agent(&self, id: &str) -> Option<&RegistryAgent> {
|
||||
self.agents.iter().find(|agent| agent.id().as_ref() == id)
|
||||
pub fn agent(&self, id: &AgentId) -> Option<&RegistryAgent> {
|
||||
self.agents.iter().find(|agent| agent.id() == id)
|
||||
}
|
||||
|
||||
pub fn is_fetching(&self) -> bool {
|
||||
@@ -364,7 +364,7 @@ async fn build_registry_agents(
|
||||
.await?;
|
||||
|
||||
let metadata = RegistryAgentMetadata {
|
||||
id: entry.id.into(),
|
||||
id: AgentId::new(entry.id),
|
||||
name: entry.name.into(),
|
||||
description: entry.description.into(),
|
||||
version: entry.version.into(),
|
||||
|
||||
@@ -61,28 +61,43 @@ impl std::fmt::Debug for AgentServerCommand {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct ExternalAgentServerName(pub SharedString);
|
||||
#[derive(
|
||||
Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct AgentId(pub SharedString);
|
||||
|
||||
impl 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>);
|
||||
|
||||
@@ -43,9 +43,7 @@ use crate::{
|
||||
worktree_store::WorktreeIdCounter,
|
||||
};
|
||||
pub use agent_registry_store::{AgentRegistryStore, RegistryAgent};
|
||||
pub use agent_server_store::{
|
||||
AgentServerStore, AgentServersUpdated, ExternalAgentServerName, ExternalAgentSource,
|
||||
};
|
||||
pub use agent_server_store::{AgentId, AgentServerStore, AgentServersUpdated, ExternalAgentSource};
|
||||
pub use git_store::{
|
||||
ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate,
|
||||
git_traversal::{ChildEntriesGitIter, GitEntry, GitEntryRef, GitTraversal},
|
||||
|
||||
@@ -27,7 +27,7 @@ impl ExternalAgentServer for NoopExternalAgent {
|
||||
|
||||
#[test]
|
||||
fn external_agent_server_name_display() {
|
||||
let name = ExternalAgentServerName(SharedString::from("Ext: Tool"));
|
||||
let name = AgentId(SharedString::from("Ext: Tool"));
|
||||
let mut s = String::new();
|
||||
write!(&mut s, "{name}").unwrap();
|
||||
assert_eq!(s, "Ext: Tool");
|
||||
@@ -39,7 +39,7 @@ fn sync_extension_agents_removes_previous_extension_entries() {
|
||||
|
||||
// Seed with a couple of agents that will be replaced by extensions
|
||||
store.external_agents.insert(
|
||||
ExternalAgentServerName(SharedString::from("foo-agent")),
|
||||
AgentId(SharedString::from("foo-agent")),
|
||||
ExternalAgentEntry::new(
|
||||
Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
|
||||
ExternalAgentSource::Custom,
|
||||
@@ -48,7 +48,7 @@ fn sync_extension_agents_removes_previous_extension_entries() {
|
||||
),
|
||||
);
|
||||
store.external_agents.insert(
|
||||
ExternalAgentServerName(SharedString::from("bar-agent")),
|
||||
AgentId(SharedString::from("bar-agent")),
|
||||
ExternalAgentEntry::new(
|
||||
Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
|
||||
ExternalAgentSource::Custom,
|
||||
@@ -57,7 +57,7 @@ fn sync_extension_agents_removes_previous_extension_entries() {
|
||||
),
|
||||
);
|
||||
store.external_agents.insert(
|
||||
ExternalAgentServerName(SharedString::from("custom")),
|
||||
AgentId(SharedString::from("custom")),
|
||||
ExternalAgentEntry::new(
|
||||
Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
|
||||
ExternalAgentSource::Custom,
|
||||
|
||||
@@ -9,14 +9,14 @@ use std::{any::Any, path::PathBuf, sync::Arc};
|
||||
#[test]
|
||||
fn extension_agent_constructs_proper_display_names() {
|
||||
// Verify the display name format for extension-provided agents
|
||||
let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent"));
|
||||
let name1 = AgentId(SharedString::from("Extension: Agent"));
|
||||
assert!(name1.0.contains(": "));
|
||||
|
||||
let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent"));
|
||||
let name2 = AgentId(SharedString::from("MyExt: MyAgent"));
|
||||
assert_eq!(name2.0, "MyExt: MyAgent");
|
||||
|
||||
// Non-extension agents shouldn't have the separator
|
||||
let custom = ExternalAgentServerName(SharedString::from("custom"));
|
||||
let custom = AgentId(SharedString::from("custom"));
|
||||
assert!(!custom.0.contains(": "));
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ fn sync_removes_only_extension_provided_agents() {
|
||||
|
||||
// Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
|
||||
store.external_agents.insert(
|
||||
ExternalAgentServerName(SharedString::from("Ext1: Agent1")),
|
||||
AgentId(SharedString::from("Ext1: Agent1")),
|
||||
ExternalAgentEntry::new(
|
||||
Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
|
||||
ExternalAgentSource::Extension,
|
||||
@@ -56,7 +56,7 @@ fn sync_removes_only_extension_provided_agents() {
|
||||
),
|
||||
);
|
||||
store.external_agents.insert(
|
||||
ExternalAgentServerName(SharedString::from("Ext2: Agent2")),
|
||||
AgentId(SharedString::from("Ext2: Agent2")),
|
||||
ExternalAgentEntry::new(
|
||||
Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
|
||||
ExternalAgentSource::Extension,
|
||||
@@ -65,7 +65,7 @@ fn sync_removes_only_extension_provided_agents() {
|
||||
),
|
||||
);
|
||||
store.external_agents.insert(
|
||||
ExternalAgentServerName(SharedString::from("custom-agent")),
|
||||
AgentId(SharedString::from("custom-agent")),
|
||||
ExternalAgentEntry::new(
|
||||
Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
|
||||
ExternalAgentSource::Custom,
|
||||
@@ -84,7 +84,7 @@ fn sync_removes_only_extension_provided_agents() {
|
||||
assert!(
|
||||
store
|
||||
.external_agents
|
||||
.contains_key(&ExternalAgentServerName(SharedString::from("custom-agent")))
|
||||
.contains_key(&AgentId(SharedString::from("custom-agent")))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ fn archive_launcher_constructs_with_all_fields() {
|
||||
};
|
||||
|
||||
// Verify display name construction
|
||||
let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent"));
|
||||
let expected_name = AgentId(SharedString::from("GitHub Agent"));
|
||||
assert_eq!(expected_name.0, "GitHub Agent");
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAp
|
||||
fn sync_extension_agents_registers_archive_launcher() {
|
||||
use extension::AgentServerManifestEntry;
|
||||
|
||||
let expected_name = ExternalAgentServerName(SharedString::from("Release Agent"));
|
||||
let expected_name = AgentId(SharedString::from("Release Agent"));
|
||||
assert_eq!(expected_name.0, "Release Agent");
|
||||
|
||||
// Verify the manifest entry structure for archive-based installation
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::{
|
||||
|
||||
use crate::paths::SanitizedPath;
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A list of absolute paths, in a specific order.
|
||||
///
|
||||
@@ -23,7 +23,7 @@ pub struct PathList {
|
||||
order: Arc<[usize]>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SerializedPathList {
|
||||
pub paths: String,
|
||||
pub order: String,
|
||||
@@ -119,19 +119,6 @@ impl PathList {
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for PathList {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
self.paths.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for PathList {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let paths: Vec<PathBuf> = Vec::deserialize(deserializer)?;
|
||||
Ok(PathList::new(&paths))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -103,10 +103,11 @@ use {
|
||||
feature_flags::FeatureFlagAppExt as _,
|
||||
git_ui::project_diff::ProjectDiff,
|
||||
gpui::{
|
||||
Action as _, App, AppContext as _, Bounds, KeyBinding, Modifiers, SharedString,
|
||||
VisualTestAppContext, WindowBounds, WindowHandle, WindowOptions, point, px, size,
|
||||
Action as _, App, AppContext as _, Bounds, KeyBinding, Modifiers, VisualTestAppContext,
|
||||
WindowBounds, WindowHandle, WindowOptions, point, px, size,
|
||||
},
|
||||
image::RgbaImage,
|
||||
project::AgentId,
|
||||
project_panel::ProjectPanel,
|
||||
settings::{NotifyWhenAgentWaiting, Settings as _},
|
||||
settings_ui::SettingsWindow,
|
||||
@@ -1958,7 +1959,7 @@ impl AgentServer for StubAgentServer {
|
||||
ui::IconName::ZedAssistant
|
||||
}
|
||||
|
||||
fn name(&self) -> SharedString {
|
||||
fn agent_id(&self) -> AgentId {
|
||||
"Visual Test Agent".into()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
# Plan: Show ACP Threads in the Sidebar (Revised)
|
||||
|
||||
## Problem
|
||||
|
||||
The sidebar currently only shows **Zed-native agent threads** (from `ThreadStore`/`ThreadsDatabase`). ACP threads (Claude Code, Codex, Gemini, etc.) are invisible in the sidebar once they're no longer live.
|
||||
|
||||
## Root Cause
|
||||
|
||||
`ThreadStore` and `ThreadsDatabase` only persist metadata for native threads. When `rebuild_contents` populates the sidebar, it reads from `ThreadStore` for historical threads and overlays live info from the `AgentPanel` — but non-native threads never get written to `ThreadStore`, so once they stop being live, they disappear.
|
||||
|
||||
## Solution Overview (Revised)
|
||||
|
||||
**Key change from the original plan:** We completely remove the sidebar's dependency on `ThreadStore`. Instead, the `Sidebar` itself owns a **single, unified persistence layer** — a new `SidebarDb` domain stored in the workspace DB — that tracks metadata for _all_ thread types (native and ACP). The sidebar becomes the single source of truth for what threads appear in the list.
|
||||
|
||||
### Why Remove the ThreadStore Dependency?
|
||||
|
||||
1. **Single responsibility** — The sidebar is the only consumer of "which threads to show in the list." Having it depend on `ThreadStore` (which exists primarily for native agent save/load) creates an indirect coupling that makes ACP integration awkward.
|
||||
2. **No merge logic** — The original plan required merging native `ThreadStore` data with a separate `AcpThreadMetadataDb` in `ThreadStore::reload`. By moving all sidebar metadata into one place, there's nothing to merge.
|
||||
3. **Simpler data flow** — Writers (native agent, ACP connections) push metadata to the sidebar DB. The sidebar reads from one table. No cross-crate coordination needed.
|
||||
4. **ThreadStore stays focused** — `ThreadStore` continues to manage native thread blob storage (save/load message data) without being polluted with sidebar display concerns.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌─────────────────────────┐
|
||||
│ NativeAgent │ │ ACP Connections │
|
||||
│ (on save_thread) │ │ (on create/update/list) │
|
||||
└──────────┬──────────┘ └──────────┬──────────────┘
|
||||
│ │
|
||||
│ save_sidebar_thread() │
|
||||
└──────────┬─────────────────┘
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ SidebarDb │
|
||||
│ (workspace DB) │
|
||||
│ sidebar_threads │
|
||||
└────────┬──────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ Sidebar │
|
||||
│ rebuild_contents │
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create `SidebarDb` Domain in `sidebar.rs`
|
||||
|
||||
**File:** `crates/agent_ui/src/sidebar.rs`
|
||||
|
||||
Add a `SidebarDb` domain using `db::static_connection!`, co-located in the sidebar module (or a small `persistence` submodule within `sidebar.rs` if it helps organization, but keeping it in the same file is fine for now).
|
||||
|
||||
### Schema
|
||||
|
||||
```rust
|
||||
use db::{
|
||||
sqlez::{
|
||||
bindable::Column, domain::Domain, statement::Statement,
|
||||
thread_safe_connection::ThreadSafeConnection,
|
||||
},
|
||||
sqlez_macros::sql,
|
||||
};
|
||||
|
||||
/// Lightweight metadata for any thread (native or ACP), enough to populate
|
||||
/// the sidebar list and route to the correct load path when clicked.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SidebarThreadRow {
|
||||
pub session_id: acp::SessionId,
|
||||
/// `None` for native Zed threads, `Some("claude-code")` etc. for ACP agents.
|
||||
pub agent_name: Option<String>,
|
||||
pub title: SharedString,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub folder_paths: PathList,
|
||||
}
|
||||
|
||||
pub struct SidebarDb(ThreadSafeConnection);
|
||||
|
||||
impl Domain for SidebarDb {
|
||||
const NAME: &str = stringify!(SidebarDb);
|
||||
|
||||
const MIGRATIONS: &[&str] = &[sql!(
|
||||
CREATE TABLE IF NOT EXISTS sidebar_threads(
|
||||
session_id TEXT PRIMARY KEY,
|
||||
agent_name TEXT,
|
||||
title TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
created_at TEXT,
|
||||
folder_paths TEXT,
|
||||
folder_paths_order TEXT
|
||||
) STRICT;
|
||||
)];
|
||||
}
|
||||
|
||||
db::static_connection!(SIDEBAR_DB, SidebarDb, []);
|
||||
```
|
||||
|
||||
### CRUD Methods
|
||||
|
||||
```rust
|
||||
impl SidebarDb {
|
||||
/// Upsert metadata for a thread (native or ACP).
|
||||
pub async fn save(&self, row: &SidebarThreadRow) -> Result<()> {
|
||||
let id = row.session_id.0.clone();
|
||||
let agent_name = row.agent_name.clone();
|
||||
let title = row.title.to_string();
|
||||
let updated_at = row.updated_at.to_rfc3339();
|
||||
let created_at = row.created_at.map(|dt| dt.to_rfc3339());
|
||||
let serialized = row.folder_paths.serialize();
|
||||
let (fp, fpo) = if row.folder_paths.is_empty() {
|
||||
(None, None)
|
||||
} else {
|
||||
(Some(serialized.paths), Some(serialized.order))
|
||||
};
|
||||
|
||||
self.write(move |conn| {
|
||||
let mut stmt = Statement::prepare(
|
||||
conn,
|
||||
"INSERT INTO sidebar_threads(session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
agent_name = excluded.agent_name,
|
||||
title = excluded.title,
|
||||
updated_at = excluded.updated_at,
|
||||
folder_paths = excluded.folder_paths,
|
||||
folder_paths_order = excluded.folder_paths_order",
|
||||
)?;
|
||||
let mut i = stmt.bind(&id, 1)?;
|
||||
i = stmt.bind(&agent_name, i)?;
|
||||
i = stmt.bind(&title, i)?;
|
||||
i = stmt.bind(&updated_at, i)?;
|
||||
i = stmt.bind(&created_at, i)?;
|
||||
i = stmt.bind(&fp, i)?;
|
||||
stmt.bind(&fpo, i)?;
|
||||
stmt.exec()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// List all sidebar thread metadata, ordered by updated_at descending.
|
||||
pub fn list(&self) -> Result<Vec<SidebarThreadRow>> {
|
||||
self.select::<SidebarThreadRow>(
|
||||
"SELECT session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order
|
||||
FROM sidebar_threads
|
||||
ORDER BY updated_at DESC"
|
||||
)?(())
|
||||
}
|
||||
|
||||
/// List threads for a specific folder path set.
|
||||
pub fn list_for_paths(&self, paths: &PathList) -> Result<Vec<SidebarThreadRow>> {
|
||||
let serialized = paths.serialize();
|
||||
self.select_bound::<String, SidebarThreadRow>(sql!(
|
||||
SELECT session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order
|
||||
FROM sidebar_threads
|
||||
WHERE folder_paths = ?
|
||||
ORDER BY updated_at DESC
|
||||
))?(serialized.paths)
|
||||
}
|
||||
|
||||
/// Look up a single thread by session ID.
|
||||
pub fn get(&self, session_id: &acp::SessionId) -> Result<Option<SidebarThreadRow>> {
|
||||
let id = session_id.0.clone();
|
||||
self.select_row_bound::<Arc<str>, SidebarThreadRow>(sql!(
|
||||
SELECT session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order
|
||||
FROM sidebar_threads
|
||||
WHERE session_id = ?
|
||||
))?(id)
|
||||
}
|
||||
|
||||
/// Return the total number of rows in the table.
|
||||
pub fn count(&self) -> Result<usize> {
|
||||
let count: (i32, i32) = self.select_row(sql!(
|
||||
SELECT COUNT(*) FROM sidebar_threads
|
||||
))?(())?.unwrap_or_default();
|
||||
Ok(count.0 as usize)
|
||||
}
|
||||
|
||||
/// Delete metadata for a single thread.
|
||||
pub async fn delete(&self, session_id: acp::SessionId) -> Result<()> {
|
||||
let id = session_id.0;
|
||||
self.write(move |conn| {
|
||||
let mut stmt = Statement::prepare(
|
||||
conn,
|
||||
"DELETE FROM sidebar_threads WHERE session_id = ?",
|
||||
)?;
|
||||
stmt.bind(&id, 1)?;
|
||||
stmt.exec()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete all thread metadata.
|
||||
pub async fn delete_all(&self) -> Result<()> {
|
||||
self.write(move |conn| {
|
||||
let mut stmt = Statement::prepare(
|
||||
conn,
|
||||
"DELETE FROM sidebar_threads",
|
||||
)?;
|
||||
stmt.exec()
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `Column` Implementation
|
||||
|
||||
```rust
|
||||
impl Column for SidebarThreadRow {
|
||||
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
|
||||
let (id, next): (Arc<str>, i32) = Column::column(statement, start_index)?;
|
||||
let (agent_name, next): (Option<String>, i32) = Column::column(statement, next)?;
|
||||
let (title, next): (String, i32) = Column::column(statement, next)?;
|
||||
let (updated_at_str, next): (String, i32) = Column::column(statement, next)?;
|
||||
let (created_at_str, next): (Option<String>, i32) = Column::column(statement, next)?;
|
||||
let (folder_paths_str, next): (Option<String>, i32) = Column::column(statement, next)?;
|
||||
let (folder_paths_order_str, next): (Option<String>, i32) = Column::column(statement, next)?;
|
||||
|
||||
let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc);
|
||||
let created_at = created_at_str
|
||||
.as_deref()
|
||||
.map(DateTime::parse_from_rfc3339)
|
||||
.transpose()?
|
||||
.map(|dt| dt.with_timezone(&Utc));
|
||||
|
||||
let folder_paths = folder_paths_str
|
||||
.map(|paths| {
|
||||
PathList::deserialize(&util::path_list::SerializedPathList {
|
||||
paths,
|
||||
order: folder_paths_order_str.unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok((
|
||||
SidebarThreadRow {
|
||||
session_id: acp::SessionId::new(id),
|
||||
agent_name,
|
||||
title: title.into(),
|
||||
updated_at,
|
||||
created_at,
|
||||
folder_paths,
|
||||
},
|
||||
next,
|
||||
))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- `SIDEBAR_DB` is a `LazyLock` static — initialized on first use, no manual connection management.
|
||||
- The `agent_name` column is `NULL` for native Zed threads and a string like `"claude-code"` for ACP agents. This replaces the `agent_type` field from the original plan.
|
||||
- The DB file lives alongside other `static_connection!` databases.
|
||||
- `ThreadsDatabase` and `ThreadStore` are **completely unchanged** by this step.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Replace `ThreadStore` Reads in `rebuild_contents` with `SidebarDb` Reads
|
||||
|
||||
**File:** `crates/agent_ui/src/sidebar.rs`
|
||||
|
||||
### Remove `ThreadStore` Dependency
|
||||
|
||||
1. **Remove** `ThreadStore::global(cx)` and `ThreadStore::try_global(cx)` from `Sidebar::new` and `rebuild_contents`.
|
||||
2. **Remove** the `cx.observe_in(&thread_store, ...)` subscription that triggers `update_entries` when `ThreadStore` changes.
|
||||
3. **Replace** `thread_store.read(cx).threads_for_paths(&path_list)` calls with `SIDEBAR_DB.list_for_paths(&path_list)` (or read all rows once at the top of `rebuild_contents` and index them in memory, which is simpler and avoids repeated DB calls).
|
||||
|
||||
### New Data Flow in `rebuild_contents`
|
||||
|
||||
```rust
|
||||
fn rebuild_contents(&mut self, cx: &App) {
|
||||
// ... existing workspace iteration setup ...
|
||||
|
||||
// Read ALL sidebar thread metadata once, index by folder_paths.
|
||||
let all_sidebar_threads = SIDEBAR_DB.list().unwrap_or_default();
|
||||
let mut threads_by_paths: HashMap<PathList, Vec<SidebarThreadRow>> = HashMap::new();
|
||||
for row in all_sidebar_threads {
|
||||
threads_by_paths
|
||||
.entry(row.folder_paths.clone())
|
||||
.or_default()
|
||||
.push(row);
|
||||
}
|
||||
|
||||
for (ws_index, workspace) in workspaces.iter().enumerate() {
|
||||
// ... existing absorbed-workspace logic ...
|
||||
|
||||
let path_list = workspace_path_list(workspace, cx);
|
||||
|
||||
if should_load_threads {
|
||||
let mut seen_session_ids: HashSet<acp::SessionId> = HashSet::new();
|
||||
|
||||
// Read from SidebarDb instead of ThreadStore
|
||||
if let Some(rows) = threads_by_paths.get(&path_list) {
|
||||
for row in rows {
|
||||
seen_session_ids.insert(row.session_id.clone());
|
||||
let (agent, icon) = match &row.agent_name {
|
||||
None => (Agent::NativeAgent, IconName::ZedAgent),
|
||||
Some(name) => (
|
||||
Agent::Custom { name: name.clone().into() },
|
||||
IconName::ZedAgent, // placeholder, resolved in Step 5
|
||||
),
|
||||
};
|
||||
threads.push(ThreadEntry {
|
||||
agent,
|
||||
session_info: AgentSessionInfo {
|
||||
session_id: row.session_id.clone(),
|
||||
cwd: None,
|
||||
title: Some(row.title.clone()),
|
||||
updated_at: Some(row.updated_at),
|
||||
created_at: row.created_at,
|
||||
meta: None,
|
||||
},
|
||||
icon,
|
||||
icon_from_external_svg: None,
|
||||
status: AgentThreadStatus::default(),
|
||||
workspace: ThreadEntryWorkspace::Open(workspace.clone()),
|
||||
is_live: false,
|
||||
is_background: false,
|
||||
highlight_positions: Vec::new(),
|
||||
worktree_name: None,
|
||||
worktree_highlight_positions: Vec::new(),
|
||||
diff_stats: DiffStats::default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ... existing linked git worktree logic, also reading from threads_by_paths ...
|
||||
// ... existing live thread overlay logic (unchanged) ...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### What Changes
|
||||
|
||||
- `rebuild_contents` reads from `SIDEBAR_DB` instead of `ThreadStore`.
|
||||
- The `ThreadEntry.agent` field now carries `Agent::Custom { name }` for ACP threads, enabling correct routing in `activate_thread`.
|
||||
- The live thread overlay logic (from `all_thread_infos_for_workspace`) is **unchanged** — it still reads from `AgentPanel` to get real-time status of running threads.
|
||||
|
||||
### What Stays the Same
|
||||
|
||||
- The entire workspace/absorbed-workspace/git-worktree structure.
|
||||
- The live thread overlay pass.
|
||||
- The notification tracking logic.
|
||||
- The search/filter logic.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Write Native Thread Metadata to `SidebarDb`
|
||||
|
||||
**File:** `crates/agent_ui/src/sidebar.rs` and/or `crates/agent_ui/src/agent_panel.rs`
|
||||
|
||||
When a native thread is saved (after conversation, on title update, etc.), we also write its metadata to `SidebarDb`. There are two approaches:
|
||||
|
||||
### Option A: Subscribe to `ThreadStore` Changes (Recommended)
|
||||
|
||||
Keep a one-directional sync: when `ThreadStore` finishes a `save_thread` or `reload`, the sidebar syncs the metadata to `SidebarDb`. This can be done in the sidebar's workspace subscription or by observing `ThreadStore` changes purely for the purpose of syncing (not for reading).
|
||||
|
||||
```rust
|
||||
// In Sidebar::subscribe_to_workspace or a dedicated sync method:
|
||||
fn sync_native_threads_to_sidebar_db(&self, cx: &App) {
|
||||
if let Some(thread_store) = ThreadStore::try_global(cx) {
|
||||
let entries: Vec<_> = thread_store.read(cx).entries().collect();
|
||||
cx.background_spawn(async move {
|
||||
for meta in entries {
|
||||
SIDEBAR_DB.save(&SidebarThreadRow {
|
||||
session_id: meta.id,
|
||||
agent_name: None, // native
|
||||
title: meta.title,
|
||||
updated_at: meta.updated_at,
|
||||
created_at: meta.created_at,
|
||||
folder_paths: meta.folder_paths,
|
||||
}).await.log_err();
|
||||
}
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Option B: Write at the Point of Save
|
||||
|
||||
In `AgentPanel` or wherever `thread_store.save_thread()` is called, also call `SIDEBAR_DB.save(...)`. This is more direct but requires touching more call sites.
|
||||
|
||||
**Recommendation:** Option A is simpler for the initial implementation. We observe `ThreadStore` changes, diff against `SidebarDb`, and sync. Later, if we want to remove `ThreadStore` entirely from the write path for native threads, we can switch to Option B.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Write ACP Thread Metadata to `SidebarDb`
|
||||
|
||||
**File:** `crates/agent_ui/src/connection_view.rs` (or `agent_panel.rs`)
|
||||
|
||||
When ACP sessions are created, updated, or listed, write metadata directly to `SidebarDb`:
|
||||
|
||||
- **On new session creation:** After `connection.new_session()` returns the `AcpThread`, call `SIDEBAR_DB.save(...)`.
|
||||
- **On title update:** ACP threads receive title updates via `SessionInfoUpdate`. When these come in, call `SIDEBAR_DB.save(...)` with the new title and updated timestamp.
|
||||
- **On session list refresh:** When `AgentSessionList::list_sessions` returns for an ACP agent, bulk-sync the metadata into `SidebarDb`.
|
||||
|
||||
After any write, call `cx.notify()` on the `Sidebar` entity (or use a channel/event) to trigger a `rebuild_contents`.
|
||||
|
||||
### Triggering Sidebar Refresh
|
||||
|
||||
Since the sidebar no longer observes `ThreadStore`, we need a mechanism to trigger `rebuild_contents` after DB writes. Options:
|
||||
|
||||
1. **Emit an event from `AgentPanel`** — The sidebar already subscribes to `AgentPanelEvent`. Add a new variant like `AgentPanelEvent::ThreadMetadataChanged` and emit it after saving to `SidebarDb`.
|
||||
2. **Use `cx.notify()` directly** — If the save happens within a `Sidebar` method, just call `self.update_entries(cx)`.
|
||||
3. **Observe a lightweight signal entity** — A simple `Entity<()>` that gets notified after DB writes.
|
||||
|
||||
**Recommendation:** Option 1 (emit from `AgentPanel`) is cleanest since the sidebar already subscribes to panel events.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Handle Agent Icon Resolution for ACP Threads
|
||||
|
||||
**File:** `crates/agent_ui/src/sidebar.rs`
|
||||
|
||||
For ACP threads in the sidebar, we need the correct agent icon. The `agent_name` string stored in `SidebarDb` maps to an agent in the `AgentServerStore`, which has icon info.
|
||||
|
||||
In `rebuild_contents`, after building the initial thread list from `SidebarDb`, resolve icons for ACP threads:
|
||||
|
||||
```rust
|
||||
// For ACP threads, look up the icon from the agent server store
|
||||
if let Some(name) = &row.agent_name {
|
||||
if let Some(agent_server_store) = /* get from workspace */ {
|
||||
// resolve icon from agent_server_store using name
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Handle Delete Operations Correctly
|
||||
|
||||
**File:** `crates/agent_ui/src/sidebar.rs`
|
||||
|
||||
When the user deletes a thread from the sidebar:
|
||||
|
||||
- **All threads** → Delete from `SidebarDb` via `SIDEBAR_DB.delete(session_id)`.
|
||||
- **Native threads** → _Also_ delete from `ThreadStore`/`ThreadsDatabase` (to clean up the blob data).
|
||||
- **ACP threads** → Optionally notify the ACP server via `AgentSessionList::delete_session`.
|
||||
|
||||
The `agent_name` field on `SidebarThreadRow` (or the `Agent` enum on `ThreadEntry`) tells us which path to take.
|
||||
|
||||
When the user clears all history:
|
||||
|
||||
```rust
|
||||
// Delete all sidebar metadata
|
||||
SIDEBAR_DB.delete_all().await?;
|
||||
// Also clear native thread blobs
|
||||
thread_store.delete_threads(cx);
|
||||
// Optionally notify ACP servers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Handle `activate_thread` Routing
|
||||
|
||||
**File:** `crates/agent_ui/src/sidebar.rs`, `crates/agent_ui/src/agent_panel.rs`
|
||||
|
||||
In `activate_thread`, branch on the `Agent` variant:
|
||||
|
||||
- `Agent::NativeAgent` → Call `panel.load_agent_thread(Agent::NativeAgent, session_id, ...)` (current behavior).
|
||||
- `Agent::Custom { name }` → Call `panel.load_agent_thread(Agent::Custom { name }, session_id, ...)` so it routes to the correct `AgentConnection::load_session`.
|
||||
|
||||
This is already partially set up — `activate_thread` takes an `Agent` parameter. The key change is that `ThreadEntry` now carries the correct `Agent` variant based on `SidebarThreadRow.agent_name`.
|
||||
|
||||
---
|
||||
|
||||
## Step 8: Handle `activate_archived_thread` Without ThreadStore
|
||||
|
||||
**File:** `crates/agent_ui/src/sidebar.rs`
|
||||
|
||||
Currently, `activate_archived_thread` looks up `saved_path_list` from `ThreadStore`:
|
||||
|
||||
```rust
|
||||
let saved_path_list = ThreadStore::try_global(cx).and_then(|thread_store| {
|
||||
thread_store
|
||||
.read(cx)
|
||||
.thread_from_session_id(&session_info.session_id)
|
||||
.map(|thread| thread.folder_paths.clone())
|
||||
});
|
||||
```
|
||||
|
||||
Replace this with a targeted `SidebarDb::get` lookup (single-row SELECT, no full table scan):
|
||||
|
||||
```rust
|
||||
let saved_path_list = SIDEBAR_DB
|
||||
.get(&session_info.session_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|row| row.folder_paths);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 9: Error Handling for Offline Agents
|
||||
|
||||
When an ACP thread is clicked but the agent server is not running:
|
||||
|
||||
- Show a toast/notification explaining the agent is offline.
|
||||
- Keep the metadata in the sidebar (don't remove it).
|
||||
- Optionally offer to start the agent server.
|
||||
|
||||
---
|
||||
|
||||
## Step 10: Migration — Backfill Existing Native Threads
|
||||
|
||||
On first launch after this change, the `SidebarDb` will be empty while `ThreadsDatabase` has existing native threads. We need a one-time backfill:
|
||||
|
||||
```rust
|
||||
// In Sidebar::new or a dedicated init method:
|
||||
fn backfill_native_threads_if_needed(cx: &App) {
|
||||
if SIDEBAR_DB.count() > 0 {
|
||||
return; // Already populated
|
||||
}
|
||||
|
||||
if let Some(thread_store) = ThreadStore::try_global(cx) {
|
||||
let entries: Vec<_> = thread_store.read(cx).entries().collect();
|
||||
cx.background_spawn(async move {
|
||||
for meta in entries {
|
||||
SIDEBAR_DB.save(&SidebarThreadRow {
|
||||
session_id: meta.id,
|
||||
agent_name: None,
|
||||
title: meta.title,
|
||||
updated_at: meta.updated_at,
|
||||
created_at: meta.created_at,
|
||||
folder_paths: meta.folder_paths,
|
||||
}).await.log_err();
|
||||
}
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary of Files to Change
|
||||
|
||||
| File | Changes |
|
||||
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `crates/agent_ui/Cargo.toml` | Add `db.workspace = true`, `sqlez.workspace = true`, `sqlez_macros.workspace = true`, `chrono.workspace = true` dependencies |
|
||||
| `crates/agent_ui/src/sidebar.rs` | **Main changes.** Add `SidebarDb` domain + `SIDEBAR_DB` static + `SidebarThreadRow`. Replace all `ThreadStore` reads in `rebuild_contents` with `SidebarDb` reads. Update `activate_archived_thread`. Add native thread sync logic. Add backfill on first run. |
|
||||
| `crates/agent_ui/src/agent_panel.rs` | Emit `AgentPanelEvent::ThreadMetadataChanged` after thread saves. Potentially write ACP metadata to `SidebarDb` here. |
|
||||
| `crates/agent_ui/src/connection_view.rs` | Write ACP metadata to `SidebarDb` on session creation, title updates, and session list refreshes. |
|
||||
|
||||
## What Is NOT Changed
|
||||
|
||||
| File / Area | Why |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `threads` table schema | No migration needed — native blob persistence is completely untouched |
|
||||
| `ThreadsDatabase` methods | `save_thread_sync`, `load_thread`, `list_threads`, `delete_thread`, `delete_threads` — all unchanged |
|
||||
| `ThreadStore` struct/methods | Stays exactly as-is. It's still used for native thread blob save/load. The sidebar just no longer reads from it for display. |
|
||||
| `NativeAgent::load_thread` / `open_thread` | These deserialize `DbThread` blobs — completely unaffected |
|
||||
| `crates/acp_thread/` | No new persistence module needed there (unlike the original plan) |
|
||||
| `crates/agent/src/db.rs` | `DbThreadMetadata` is unchanged — no `agent_type` field added |
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. **SidebarDb domain** (Step 1) — Create `SidebarDb`, `SidebarThreadRow`, `SIDEBAR_DB` static, CRUD methods in `sidebar.rs`.
|
||||
2. **Replace reads** (Step 2) — Swap `ThreadStore` reads in `rebuild_contents` for `SidebarDb` reads.
|
||||
3. **Native write path** (Step 3) — Sync native thread metadata from `ThreadStore` into `SidebarDb`.
|
||||
4. **ACP write path** (Step 4) — Write ACP thread metadata to `SidebarDb` from connection views.
|
||||
5. **Icon resolution** (Step 5) — Resolve ACP agent icons in the sidebar.
|
||||
6. **Delete path** (Step 6) — Route deletes to `SidebarDb` + native blob cleanup + ACP server notification.
|
||||
7. **Activate routing** (Step 7) — Ensure `activate_thread` routes correctly based on `Agent` variant.
|
||||
8. **Archive fix** (Step 8) — Update `activate_archived_thread` to use `SidebarDb`.
|
||||
9. **Migration** (Step 10) — Backfill existing native threads on first run.
|
||||
10. **Polish** (Step 9) — Error handling for offline agents.
|
||||
|
||||
## Key Differences from Original Plan
|
||||
|
||||
| Aspect | Original Plan | Revised Plan |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
|
||||
| **Where ACP metadata lives** | New `AcpThreadMetadataDb` in `crates/acp_thread/` | `SidebarDb` in `crates/agent_ui/src/sidebar.rs` |
|
||||
| **Where sidebar reads from** | `ThreadStore` (which merges native + ACP) | `SidebarDb` directly (single source) |
|
||||
| **ThreadStore changes** | Added `agent_type` to `DbThreadMetadata`, merge logic in `reload`, new save/delete methods | **None** — ThreadStore is untouched |
|
||||
| **`crates/agent/src/db.rs` changes** | Added `agent_type: Option<String>` to `DbThreadMetadata` | **None** |
|
||||
| **Merge complexity** | Two data sources merged in `ThreadStore::reload` | No merge — one table, one read |
|
||||
| **Crate dependencies** | `acp_thread` gains `db` dependency | `agent_ui` gains `db` dependency (more natural — it's a UI persistence concern) |
|
||||
Reference in new issue
Block a user