From df930db9ddd34e1bc6ada5f43ec08ee1a4bd2b1f 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 87f2442a05..b139b679dc 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 f0fdc629e7..aaf170928c 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 f66054feb3..cedbd4acdd 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 41d9f160fc..8fb0da2bc0 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 0c8fd1da2e..d10354981c 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 b73154c472..0a21a28b0e 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 8bbb02ffa7..1378a9f02d 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 1a65ca78ff..51aa88bf70 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")