From cd12d45e4aaeae53f14d682d7c1fe4f19adf8c1f Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 14 Jan 2026 14:41:31 +0100 Subject: [PATCH] agent: Initial support for ACP session listing (#46796) 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(-) diff --git a/Cargo.lock b/Cargo.lock index 87f2442a05847bac0eca63fc4564f0855351006a..b139b679dcc981f2c4a19de935cdeaa55f2a3ccd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -268,6 +268,7 @@ dependencies = [ "agent-client-protocol", "anyhow", "async-trait", + "chrono", "client", "collections", "env_logger 0.11.8", diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index f0fdc629e78fdea07ee8d4e88cb9a1184986e37f..aaf170928cf82719990e9b46a1e182b919db0102 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -217,12 +217,16 @@ pub trait AgentSessionList { cx: &mut App, ) -> Task>; + fn supports_delete(&self) -> bool { + false + } + fn delete_session(&self, _session_id: &acp::SessionId, _cx: &mut App) -> Task> { - Task::ready(Ok(())) + Task::ready(Err(anyhow::anyhow!("delete_session not supported"))) } fn delete_sessions(&self, _cx: &mut App) -> Task> { - Task::ready(Ok(())) + Task::ready(Err(anyhow::anyhow!("delete_sessions not supported"))) } fn watch(&self, _cx: &mut App) -> Option> { diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index f66054feb311d6830eb62a6714df2db36619d6b4..cedbd4acdddfbddcbcd2de352e4d411a99337611 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -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> { self.thread_store .update(cx, |store, cx| store.delete_thread(session_id.clone(), cx)) diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index 41d9f160fc073000df0a03efb9eb06ca3d520462..8fb0da2bc0de46351c5e7baf0f294095a031c8ed 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -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 } diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 0c8fd1da2e0281bf2feac054d3094eb212efde47..d10354981c2065d91bf310b1e308a240345bdd2d 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -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, root_dir: PathBuf, child: Child, + session_list: Option>, _io_task: Task>, _wait_task: Task>, _stderr_task: Task>, @@ -74,6 +78,70 @@ pub struct AcpSession { config_options: Option, } +pub struct AcpSessionList { + connection: Rc, + updates_tx: Rc>>, + updates_rx: watch::Receiver<()>, +} + +impl AcpSessionList { + fn new(connection: Rc) -> 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> { + 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> { + Some(self.updates_rx.clone()) + } + + fn into_any(self: Rc) -> Rc { + 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> { + if cx.has_flag::() { + self.session_list.clone().map(|s| s as _) + } else { + None + } + } + fn into_any(self: Rc) -> Rc { self } diff --git a/crates/agent_ui/src/acp/thread_history.rs b/crates/agent_ui/src/acp/thread_history.rs index b73154c472566b844e40457574040431bf1485bf..0a21a28b0e3c6c95701e678638948908e2e7a2fc 100644 --- a/crates/agent_ui/src/acp/thread_history.rs +++ b/crates/agent_ui/src/acp/thread_history.rs @@ -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.refresh_sessions(true, cx); + } + pub fn session_for_id(&self, session_id: &acp::SessionId) -> Option { 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) { - 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::(if hovered { + .end_slot::(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, selected: bool, hovered: bool, + supports_delete: bool, on_hover: Box, } @@ -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::(if self.hovered || self.selected { + .end_slot::(if (self.hovered || self.selected) && self.supports_delete { Some( IconButton::new("delete", IconName::Trash) .shape(IconButtonShape::Square) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 8bbb02ffa7027962ecaa043c4b5231fdbc39924c..1378a9f02d1eb201d658b8ba7f325fb17530455c 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -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() diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 1a65ca78ff506df5e8b8457d17c07b2fed6c5f90..51aa88bf7029e06b82ba59e2f621db268d0b1738 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -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 { + fn history_kind_for_selected_agent(&self, cx: &App) -> Option { 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) { - 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, ) { - 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")