Cargo.lock 🔗
@@ -268,6 +268,7 @@ dependencies = [
"agent-client-protocol",
"anyhow",
"async-trait",
+ "chrono",
"client",
"collections",
"env_logger 0.11.8",
Ben Brandt created
Feature flagged for now as we test this out with actual agents to see if
we need to provide any more feedback to the RFD before committing to the
current setup.
Release Notes:
- N/A
Cargo.lock | 1
crates/acp_thread/src/connection.rs | 8 +
crates/agent/src/agent.rs | 4 +
crates/agent_servers/Cargo.toml | 1
crates/agent_servers/src/acp.rs | 94 ++++++++++++++++++++++++
crates/agent_ui/src/acp/thread_history.rs | 39 +++++++++-
crates/agent_ui/src/acp/thread_view.rs | 59 +++++++-------
crates/agent_ui/src/agent_panel.rs | 18 +++-
8 files changed, 180 insertions(+), 44 deletions(-)
@@ -268,6 +268,7 @@ dependencies = [
"agent-client-protocol",
"anyhow",
"async-trait",
+ "chrono",
"client",
"collections",
"env_logger 0.11.8",
@@ -217,12 +217,16 @@ pub trait AgentSessionList {
cx: &mut App,
) -> Task<Result<AgentSessionListResponse>>;
+ fn supports_delete(&self) -> bool {
+ false
+ }
+
fn delete_session(&self, _session_id: &acp::SessionId, _cx: &mut App) -> Task<Result<()>> {
- Task::ready(Ok(()))
+ Task::ready(Err(anyhow::anyhow!("delete_session not supported")))
}
fn delete_sessions(&self, _cx: &mut App) -> Task<Result<()>> {
- Task::ready(Ok(()))
+ Task::ready(Err(anyhow::anyhow!("delete_sessions not supported")))
}
fn watch(&self, _cx: &mut App) -> Option<watch::Receiver<()>> {
@@ -1428,6 +1428,10 @@ impl AgentSessionList for NativeAgentSessionList {
Task::ready(Ok(AgentSessionListResponse::new(sessions)))
}
+ fn supports_delete(&self) -> bool {
+ true
+ }
+
fn delete_session(&self, session_id: &acp::SessionId, cx: &mut App) -> Task<Result<()>> {
self.thread_store
.update(cx, |store, cx| store.delete_thread(session_id.clone(), cx))
@@ -24,6 +24,7 @@ agent-client-protocol.workspace = true
feature_flags.workspace = true
anyhow.workspace = true
async-trait.workspace = true
+chrono.workspace = true
client.workspace = true
collections.workspace = true
env_logger = { workspace = true, optional = true }
@@ -1,4 +1,7 @@
-use acp_thread::AgentConnection;
+use acp_thread::{
+ AgentConnection, AgentSessionInfo, AgentSessionList, AgentSessionListRequest,
+ AgentSessionListResponse,
+};
use acp_tools::AcpConnectionRegistry;
use action_log::ActionLog;
use agent_client_protocol::{self as acp, Agent as _, ErrorCode};
@@ -44,6 +47,7 @@ pub struct AcpConnection {
default_config_options: HashMap<String, String>,
root_dir: PathBuf,
child: Child,
+ session_list: Option<Rc<AcpSessionList>>,
_io_task: Task<Result<(), acp::Error>>,
_wait_task: Task<Result<()>>,
_stderr_task: Task<Result<()>>,
@@ -74,6 +78,70 @@ pub struct AcpSession {
config_options: Option<ConfigOptions>,
}
+pub struct AcpSessionList {
+ connection: Rc<acp::ClientSideConnection>,
+ updates_tx: Rc<RefCell<watch::Sender<()>>>,
+ updates_rx: watch::Receiver<()>,
+}
+
+impl AcpSessionList {
+ fn new(connection: Rc<acp::ClientSideConnection>) -> Self {
+ let (tx, rx) = watch::channel(());
+ Self {
+ connection,
+ updates_tx: Rc::new(RefCell::new(tx)),
+ updates_rx: rx,
+ }
+ }
+
+ fn notify_update(&self) {
+ self.updates_tx.borrow_mut().send(()).ok();
+ }
+}
+
+impl AgentSessionList for AcpSessionList {
+ fn list_sessions(
+ &self,
+ request: AgentSessionListRequest,
+ cx: &mut App,
+ ) -> Task<Result<AgentSessionListResponse>> {
+ let conn = self.connection.clone();
+ cx.foreground_executor().spawn(async move {
+ let acp_request = acp::ListSessionsRequest::new()
+ .cwd(request.cwd)
+ .cursor(request.cursor);
+ let response = conn.list_sessions(acp_request).await?;
+ Ok(AgentSessionListResponse {
+ sessions: response
+ .sessions
+ .into_iter()
+ .map(|s| AgentSessionInfo {
+ session_id: s.session_id,
+ cwd: Some(s.cwd),
+ title: s.title.map(Into::into),
+ updated_at: s.updated_at.and_then(|date_str| {
+ chrono::DateTime::parse_from_rfc3339(&date_str)
+ .ok()
+ .map(|dt| dt.with_timezone(&chrono::Utc))
+ }),
+ meta: s.meta,
+ })
+ .collect(),
+ next_cursor: response.next_cursor,
+ meta: response.meta,
+ })
+ })
+ }
+
+ fn watch(&self, _cx: &mut App) -> Option<watch::Receiver<()>> {
+ Some(self.updates_rx.clone())
+ }
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+ self
+ }
+}
+
pub async fn connect(
server_name: SharedString,
command: AgentServerCommand,
@@ -226,6 +294,17 @@ impl AcpConnection {
// Otherwise, just use the name
.unwrap_or_else(|| server_name.clone());
+ let session_list = if response
+ .agent_capabilities
+ .session_capabilities
+ .list
+ .is_some()
+ {
+ Some(Rc::new(AcpSessionList::new(connection.clone())))
+ } else {
+ None
+ };
+
Ok(Self {
auth_methods: response.auth_methods,
root_dir: root_dir.to_owned(),
@@ -237,6 +316,7 @@ impl AcpConnection {
default_mode,
default_model,
default_config_options,
+ session_list,
_io_task: io_task,
_wait_task: wait_task,
_stderr_task: stderr_task,
@@ -556,6 +636,10 @@ impl AgentConnection for AcpConnection {
};
sessions.borrow_mut().insert(session_id, session);
+ if let Some(session_list) = &self.session_list {
+ session_list.notify_update();
+ }
+
Ok(thread)
})
}
@@ -707,6 +791,14 @@ impl AgentConnection for AcpConnection {
}) as _)
}
+ fn session_list(&self, cx: &mut App) -> Option<Rc<dyn AgentSessionList>> {
+ if cx.has_flag::<AcpBetaFeatureFlag>() {
+ self.session_list.clone().map(|s| s as _)
+ } else {
+ None
+ }
+ }
+
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
self
}
@@ -246,6 +246,14 @@ impl AcpThreadHistory {
self.sessions.is_empty()
}
+ pub fn has_session_list(&self) -> bool {
+ self.session_list.is_some()
+ }
+
+ pub fn refresh(&mut self, cx: &mut Context<Self>) {
+ self.refresh_sessions(true, cx);
+ }
+
pub fn session_for_id(&self, session_id: &acp::SessionId) -> Option<AgentSessionInfo> {
self.sessions
.iter()
@@ -261,6 +269,13 @@ impl AcpThreadHistory {
self.sessions.iter().take(limit).cloned().collect()
}
+ pub fn supports_delete(&self) -> bool {
+ self.session_list
+ .as_ref()
+ .map(|sl| sl.supports_delete())
+ .unwrap_or(false)
+ }
+
pub(crate) fn delete_session(
&self,
session_id: &acp::SessionId,
@@ -455,14 +470,21 @@ impl AcpThreadHistory {
let Some(session_list) = self.session_list.as_ref() else {
return;
};
+ if !session_list.supports_delete() {
+ return;
+ }
let task = session_list.delete_session(&entry.session_id, cx);
task.detach_and_log_err(cx);
}
fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
- if let Some(session_list) = self.session_list.as_ref() {
- session_list.delete_sessions(cx).detach_and_log_err(cx);
+ let Some(session_list) = self.session_list.as_ref() else {
+ return;
+ };
+ if !session_list.supports_delete() {
+ return;
}
+ session_list.delete_sessions(cx).detach_and_log_err(cx);
self.confirming_delete_history = false;
cx.notify();
}
@@ -585,7 +607,7 @@ impl AcpThreadHistory {
cx.notify();
}))
- .end_slot::<IconButton>(if hovered {
+ .end_slot::<IconButton>(if hovered && self.supports_delete() {
Some(
IconButton::new("delete", IconName::Trash)
.shape(IconButtonShape::Square)
@@ -682,7 +704,7 @@ impl Render for AcpThreadHistory {
.vertical_scrollbar_for(&self.scroll_handle, window, cx)
}
})
- .when(!has_no_history, |this| {
+ .when(!has_no_history && self.supports_delete(), |this| {
this.child(
h_flex()
.p_2()
@@ -753,6 +775,7 @@ pub struct AcpHistoryEntryElement {
thread_view: WeakEntity<AcpThreadView>,
selected: bool,
hovered: bool,
+ supports_delete: bool,
on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
}
@@ -763,10 +786,16 @@ impl AcpHistoryEntryElement {
thread_view,
selected: false,
hovered: false,
+ supports_delete: false,
on_hover: Box::new(|_, _, _| {}),
}
}
+ pub fn supports_delete(mut self, supports_delete: bool) -> Self {
+ self.supports_delete = supports_delete;
+ self
+ }
+
pub fn hovered(mut self, hovered: bool) -> Self {
self.hovered = hovered;
self
@@ -818,7 +847,7 @@ impl RenderOnce for AcpHistoryEntryElement {
),
)
.on_hover(self.on_hover)
- .end_slot::<IconButton>(if self.hovered || self.selected {
+ .end_slot::<IconButton>(if (self.hovered || self.selected) && self.supports_delete {
Some(
IconButton::new("delete", IconName::Trash)
.shape(IconButtonShape::Square)
@@ -1863,6 +1863,8 @@ impl AcpThreadView {
} else if !self.message_queue.is_empty() {
self.send_queued_message_at_index(0, false, window, cx);
}
+
+ self.history.update(cx, |history, cx| history.refresh(cx));
}
AcpThreadEvent::Refusal => {
self.thread_retry_status.take();
@@ -1897,6 +1899,7 @@ impl AcpThreadView {
}
});
}
+ self.history.update(cx, |history, cx| history.refresh(cx));
}
AcpThreadEvent::PromptCapabilitiesUpdated => {
self.prompt_capabilities
@@ -4242,36 +4245,32 @@ impl AcpThreadView {
cx,
),
)
- .child(
- v_flex().p_1().pr_1p5().gap_1().children(
- recent_history
- .into_iter()
- .enumerate()
- .map(|(index, entry)| {
- // TODO: Add keyboard navigation.
- let is_hovered =
- self.hovered_recent_history_item == Some(index);
- crate::acp::thread_history::AcpHistoryEntryElement::new(
- entry,
- cx.entity().downgrade(),
- )
- .hovered(is_hovered)
- .on_hover(cx.listener(
- move |this, is_hovered, _window, cx| {
- if *is_hovered {
- this.hovered_recent_history_item = Some(index);
- } else if this.hovered_recent_history_item
- == Some(index)
- {
- this.hovered_recent_history_item = None;
- }
- cx.notify();
- },
- ))
- .into_any_element()
- }),
- ),
- ),
+ .child(v_flex().p_1().pr_1p5().gap_1().children({
+ let supports_delete = self.history.read(cx).supports_delete();
+ recent_history
+ .into_iter()
+ .enumerate()
+ .map(move |(index, entry)| {
+ // TODO: Add keyboard navigation.
+ let is_hovered =
+ self.hovered_recent_history_item == Some(index);
+ crate::acp::thread_history::AcpHistoryEntryElement::new(
+ entry,
+ cx.entity().downgrade(),
+ )
+ .hovered(is_hovered)
+ .supports_delete(supports_delete)
+ .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
+ if *is_hovered {
+ this.hovered_recent_history_item = Some(index);
+ } else if this.hovered_recent_history_item == Some(index) {
+ this.hovered_recent_history_item = None;
+ }
+ cx.notify();
+ }))
+ .into_any_element()
+ })
+ })),
)
})
.into_any()
@@ -615,7 +615,7 @@ impl AgentPanel {
let agent_navigation_menu =
ContextMenu::build_persistent(window, cx, move |mut menu, _window, cx| {
if let Some(panel) = panel.upgrade() {
- if let Some(kind) = panel.read(cx).history_kind_for_selected_agent() {
+ if let Some(kind) = panel.read(cx).history_kind_for_selected_agent(cx) {
menu =
Self::populate_recently_updated_menu_section(menu, panel, kind, cx);
menu = menu.action("View All", Box::new(OpenHistory));
@@ -969,19 +969,25 @@ impl AgentPanel {
}
}
- fn history_kind_for_selected_agent(&self) -> Option<HistoryKind> {
+ fn history_kind_for_selected_agent(&self, cx: &App) -> Option<HistoryKind> {
match self.selected_agent {
AgentType::NativeAgent => Some(HistoryKind::AgentThreads),
AgentType::TextThread => Some(HistoryKind::TextThreads),
AgentType::Gemini
| AgentType::ClaudeCode
| AgentType::Codex
- | AgentType::Custom { .. } => None,
+ | AgentType::Custom { .. } => {
+ if self.acp_history.read(cx).has_session_list() {
+ Some(HistoryKind::AgentThreads)
+ } else {
+ None
+ }
+ }
}
}
fn open_history(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- let Some(kind) = self.history_kind_for_selected_agent() else {
+ let Some(kind) = self.history_kind_for_selected_agent(cx) else {
return;
};
@@ -1079,7 +1085,7 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
- if self.history_kind_for_selected_agent().is_none() {
+ if self.history_kind_for_selected_agent(cx).is_none() {
return;
}
self.agent_navigation_menu_handle.toggle(window, cx);
@@ -2410,7 +2416,7 @@ impl AgentPanel {
selected_agent.into_any_element()
};
- let show_history_menu = self.history_kind_for_selected_agent().is_some();
+ let show_history_menu = self.history_kind_for_selected_agent(cx).is_some();
h_flex()
.id("agent-panel-toolbar")