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")