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:
1 parent
ee291bf9f1
commit
df930db9dd
8 files changed
+180
-44
No files matched your search
Generated
+1
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user