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
This commit is contained in:
Ben Brandt authored and GitHub committed 2026-01-14 14:41:31 +01:00
1 parent ee291bf9f1
commit df930db9dd
8 files changed
+180 -44

No files matched your search

Generated
+1
View File
@@ -268,6 +268,7 @@ dependencies = [
"agent-client-protocol",
"anyhow",
"async-trait",
"chrono",
"client",
"collections",
"env_logger 0.11.8",
+6 -2
View File
@@ -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<()>> {
+4
View File
@@ -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))
+1
View File
@@ -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 }
+93 -1
View File
@@ -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
}
+34 -5
View File
@@ -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)
+29 -30
View File
@@ -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()
+12 -6
View File
@@ -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")