From db2f2ad45a98d2345b4e51120588579e4076322f Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 14 Jan 2026 11:57:43 +0100 Subject: [PATCH] agent: One Thread History (#46785) This makes sure that all of the work we do for caching/refreshing session history is reused everywhere we need to access the list. Very important for external agents so we aren't rerequesting history on every search or recent list update. Puts the reloading of history in one place Release Notes: - N/A --- Cargo.lock | 7 - crates/agent_ui/src/acp/entry_view_state.rs | 21 +- crates/agent_ui/src/acp/message_editor.rs | 68 +- crates/agent_ui/src/acp/thread_history.rs | 22 +- crates/agent_ui/src/acp/thread_view.rs | 181 ++-- crates/agent_ui/src/agent_panel.rs | 24 +- crates/agent_ui/src/completion_provider.rs | 161 +--- crates/agent_ui/src/inline_assistant.rs | 34 +- crates/agent_ui/src/inline_prompt_editor.rs | 13 +- .../agent_ui/src/terminal_inline_assistant.rs | 3 + crates/agent_ui_v2/Cargo.toml | 7 - crates/agent_ui_v2/src/agent_thread_pane.rs | 12 +- crates/agent_ui_v2/src/agent_ui_v2.rs | 1 - crates/agent_ui_v2/src/agents_panel.rs | 5 +- crates/agent_ui_v2/src/thread_history.rs | 868 ------------------ 15 files changed, 238 insertions(+), 1189 deletions(-) delete mode 100644 crates/agent_ui_v2/src/thread_history.rs diff --git a/Cargo.lock b/Cargo.lock index bde2a31806af2095fa7f2be1539038f20109aa14..87f2442a05847bac0eca63fc4564f0855351006a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -435,23 +435,16 @@ dependencies = [ "agent_settings", "agent_ui", "anyhow", - "chrono", "db", - "editor", "feature_flags", "fs", - "fuzzy", "gpui", "log", - "menu", "project", "prompt_store", "serde", "serde_json", "settings", - "text", - "time", - "time_format", "ui", "util", "workspace", diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index d023a71d7bd7343f6aefd3094d6a27705f193dc6..2d6b1e7148891020d77654f97ccc2e281557f384 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -1,6 +1,7 @@ use std::{cell::RefCell, ops::Range, rc::Rc}; -use acp_thread::{AcpThread, AgentSessionList, AgentThreadEntry}; +use super::thread_history::AcpThreadHistory; +use acp_thread::{AcpThread, AgentThreadEntry}; use agent::ThreadStore; use agent_client_protocol::{self as acp, ToolCallId}; use collections::HashMap; @@ -24,7 +25,7 @@ pub struct EntryViewState { workspace: WeakEntity, project: WeakEntity, thread_store: Option>, - session_list: Rc>>>, + history: WeakEntity, prompt_store: Option>, entries: Vec, prompt_capabilities: Rc>, @@ -37,7 +38,7 @@ impl EntryViewState { workspace: WeakEntity, project: WeakEntity, thread_store: Option>, - session_list: Rc>>>, + history: WeakEntity, prompt_store: Option>, prompt_capabilities: Rc>, available_commands: Rc>>, @@ -47,7 +48,7 @@ impl EntryViewState { workspace, project, thread_store, - session_list, + history, prompt_store, entries: Vec::new(), prompt_capabilities, @@ -89,7 +90,7 @@ impl EntryViewState { self.workspace.clone(), self.project.clone(), self.thread_store.clone(), - self.session_list.clone(), + self.history.clone(), self.prompt_store.clone(), self.prompt_capabilities.clone(), self.available_commands.clone(), @@ -400,7 +401,8 @@ fn diff_editor_text_style_refinement(cx: &mut App) -> TextStyleRefinement { #[cfg(test)] mod tests { - use std::{cell::RefCell, path::Path, rc::Rc}; + use std::path::Path; + use std::rc::Rc; use acp_thread::{AgentConnection, StubAgentConnection}; use agent_client_protocol as acp; @@ -455,14 +457,15 @@ mod tests { }); let thread_store = None; - let session_list = Rc::new(RefCell::new(None)); + let history = cx + .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); let view_state = cx.new(|_cx| { EntryViewState::new( workspace.downgrade(), project.downgrade(), thread_store, - session_list, + history.downgrade(), None, Default::default(), Default::default(), @@ -474,7 +477,7 @@ mod tests { view_state.sync_entry(0, &thread, window, cx) }); - let diff = thread.read_with(cx, |thread, _cx| { + let diff = thread.read_with(cx, |thread, _| { thread .entries() .get(0) diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index bd020529581256b950cb6d11976edb2cb8544664..81a3ba715d725dc4d966228ecc7a236f988afe06 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -1,4 +1,5 @@ use crate::QueueMessage; +use crate::acp::AcpThreadHistory; use crate::{ ChatWithFollow, completion_provider::{ @@ -9,7 +10,7 @@ use crate::{ Mention, MentionImage, MentionSet, insert_crease_for_mention, paste_images_as_context, }, }; -use acp_thread::{AgentSessionInfo, AgentSessionList, MentionUri}; +use acp_thread::{AgentSessionInfo, MentionUri}; use agent::ThreadStore; use agent_client_protocol as acp; use anyhow::{Result, anyhow}; @@ -101,7 +102,7 @@ impl MessageEditor { workspace: WeakEntity, project: WeakEntity, thread_store: Option>, - session_list: Rc>>>, + history: WeakEntity, prompt_store: Option>, prompt_capabilities: Rc>, available_commands: Rc>>, @@ -158,8 +159,7 @@ impl MessageEditor { cx.entity(), editor.downgrade(), mention_set.clone(), - thread_store.clone(), - session_list, + history, prompt_store.clone(), workspace.clone(), )); @@ -1108,7 +1108,8 @@ mod tests { cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); let thread_store = None; - let session_list = Rc::new(RefCell::new(None)); + let history = cx + .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -1116,7 +1117,7 @@ mod tests { workspace.downgrade(), project.downgrade(), thread_store.clone(), - session_list.clone(), + history.downgrade(), None, Default::default(), Default::default(), @@ -1214,13 +1215,14 @@ mod tests { let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; let thread_store = None; - let session_list = Rc::new(RefCell::new(None)); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); // Start with no available commands - simulating Claude which doesn't support slash commands let available_commands = Rc::new(RefCell::new(vec![])); let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let history = cx + .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); let workspace_handle = workspace.downgrade(); let message_editor = workspace.update_in(cx, |_, window, cx| { cx.new(|cx| { @@ -1228,7 +1230,7 @@ mod tests { workspace_handle.clone(), project.downgrade(), thread_store.clone(), - session_list.clone(), + history.downgrade(), None, prompt_capabilities.clone(), available_commands.clone(), @@ -1372,7 +1374,8 @@ mod tests { let mut cx = VisualTestContext::from_window(*window, cx); let thread_store = None; - let session_list = Rc::new(RefCell::new(None)); + let history = cx + .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![ acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"), @@ -1390,7 +1393,7 @@ mod tests { workspace_handle, project.downgrade(), thread_store.clone(), - session_list.clone(), + history.downgrade(), None, prompt_capabilities.clone(), available_commands.clone(), @@ -1603,7 +1606,8 @@ mod tests { } let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let session_list = Rc::new(RefCell::new(None)); + let history = cx + .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { @@ -1613,7 +1617,7 @@ mod tests { workspace_handle, project.downgrade(), Some(thread_store), - session_list.clone(), + history.downgrade(), None, prompt_capabilities.clone(), Default::default(), @@ -2097,7 +2101,8 @@ mod tests { cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let session_list = Rc::new(RefCell::new(None)); + let history = cx + .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -2105,7 +2110,7 @@ mod tests { workspace.downgrade(), project.downgrade(), thread_store.clone(), - session_list.clone(), + history.downgrade(), None, Default::default(), Default::default(), @@ -2196,7 +2201,8 @@ mod tests { cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let session_list = Rc::new(RefCell::new(None)); + let history = cx + .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); // Create a thread metadata to insert as summary let thread_metadata = AgentSessionInfo { @@ -2213,7 +2219,7 @@ mod tests { workspace.downgrade(), project.downgrade(), thread_store.clone(), - session_list.clone(), + history.downgrade(), None, Default::default(), Default::default(), @@ -2276,7 +2282,8 @@ mod tests { cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); let thread_store = None; - let session_list = Rc::new(RefCell::new(None)); + let history = cx + .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); let thread_metadata = AgentSessionInfo { session_id: acp::SessionId::new("thread-123"), @@ -2292,7 +2299,7 @@ mod tests { workspace.downgrade(), project.downgrade(), thread_store.clone(), - session_list.clone(), + history.downgrade(), None, Default::default(), Default::default(), @@ -2334,7 +2341,8 @@ mod tests { cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); let thread_store = None; - let session_list = Rc::new(RefCell::new(None)); + let history = cx + .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -2342,7 +2350,7 @@ mod tests { workspace.downgrade(), project.downgrade(), thread_store.clone(), - session_list.clone(), + history.downgrade(), None, Default::default(), Default::default(), @@ -2387,7 +2395,8 @@ mod tests { cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let session_list = Rc::new(RefCell::new(None)); + let history = cx + .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -2395,7 +2404,7 @@ mod tests { workspace.downgrade(), project.downgrade(), thread_store.clone(), - session_list.clone(), + history.downgrade(), None, Default::default(), Default::default(), @@ -2441,7 +2450,8 @@ mod tests { cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let session_list = Rc::new(RefCell::new(None)); + let history = cx + .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -2449,7 +2459,7 @@ mod tests { workspace.downgrade(), project.downgrade(), thread_store.clone(), - session_list.clone(), + history.downgrade(), None, Default::default(), Default::default(), @@ -2504,7 +2514,8 @@ mod tests { cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let session_list = Rc::new(RefCell::new(None)); + let history = cx + .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); @@ -2513,7 +2524,7 @@ mod tests { workspace_handle, project.downgrade(), thread_store.clone(), - session_list.clone(), + history.downgrade(), None, Default::default(), Default::default(), @@ -2660,7 +2671,8 @@ mod tests { }); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let session_list = Rc::new(RefCell::new(None)); + let history = cx + .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); // Create a new `MessageEditor`. The `EditorMode::full()` has to be used // to ensure we have a fixed viewport, so we can eventually actually @@ -2672,7 +2684,7 @@ mod tests { workspace_handle, project.downgrade(), thread_store.clone(), - session_list.clone(), + history.downgrade(), None, Default::default(), Default::default(), diff --git a/crates/agent_ui/src/acp/thread_history.rs b/crates/agent_ui/src/acp/thread_history.rs index b5fa4af7cc9eee3b0c1607e579923cd0f01a0f48..b73154c472566b844e40457574040431bf1485bf 100644 --- a/crates/agent_ui/src/acp/thread_history.rs +++ b/crates/agent_ui/src/acp/thread_history.rs @@ -72,7 +72,7 @@ pub enum ThreadHistoryEvent { impl EventEmitter for AcpThreadHistory {} impl AcpThreadHistory { - pub(crate) fn new( + pub fn new( session_list: Option>, window: &mut Window, cx: &mut Context, @@ -155,7 +155,7 @@ impl AcpThreadHistory { }); } - pub(crate) fn set_session_list( + pub fn set_session_list( &mut self, session_list: Option>, cx: &mut Context, @@ -246,7 +246,7 @@ impl AcpThreadHistory { self.sessions.is_empty() } - pub(crate) fn session_for_id(&self, session_id: &acp::SessionId) -> Option { + pub fn session_for_id(&self, session_id: &acp::SessionId) -> Option { self.sessions .iter() .find(|entry| &entry.session_id == session_id) @@ -257,6 +257,22 @@ impl AcpThreadHistory { &self.sessions } + pub(crate) fn get_recent_sessions(&self, limit: usize) -> Vec { + self.sessions.iter().take(limit).cloned().collect() + } + + pub(crate) fn delete_session( + &self, + session_id: &acp::SessionId, + cx: &mut App, + ) -> Task> { + if let Some(session_list) = self.session_list.as_ref() { + session_list.delete_session(session_id, cx) + } else { + Task::ready(Ok(())) + } + } + fn add_list_separators( &self, entries: Vec, diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 4ed217bbe8df2acd081772699a9bbb57cf5b47b9..8bbb02ffa7027962ecaa043c4b5231fdbc39924c 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1,7 +1,7 @@ use acp_thread::{ - AcpThread, AcpThreadEvent, AgentSessionInfo, AgentSessionList, AgentSessionListRequest, - AgentThreadEntry, AssistantMessage, AssistantMessageChunk, AuthRequired, LoadError, MentionUri, - RetryStatus, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus, UserMessageId, + AcpThread, AcpThreadEvent, AgentSessionInfo, AgentThreadEntry, AssistantMessage, + AssistantMessageChunk, AuthRequired, LoadError, MentionUri, RetryStatus, ThreadStatus, + ToolCall, ToolCallContent, ToolCallStatus, UserMessageId, }; use acp_thread::{AgentConnection, Plan}; use action_log::{ActionLog, ActionLogTelemetry}; @@ -61,6 +61,7 @@ use zed_actions::assistant::OpenRulesLibrary; use super::config_options::ConfigOptionsView; use super::entry_view_state::EntryViewState; +use super::thread_history::AcpThreadHistory; use crate::acp::AcpModelSelectorPopover; use crate::acp::ModeSelector; use crate::acp::entry_view_state::{EntryViewEvent, ViewEvent}; @@ -311,11 +312,9 @@ pub struct AcpThreadView { project: Entity, thread_state: ThreadState, login: Option, - session_list: Option>, - session_list_state: Rc>>>, recent_history_entries: Vec, - _recent_history_task: Task<()>, - _recent_history_watch_task: Option>, + history: Entity, + _history_subscription: Subscription, hovered_recent_history_item: Option, entry_view_state: Entity, message_editor: Entity, @@ -400,13 +399,13 @@ impl AcpThreadView { project: Entity, thread_store: Option>, prompt_store: Option>, + history: Entity, track_load_event: bool, window: &mut Window, cx: &mut Context, ) -> Self { let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![])); - let session_list_state = Rc::new(RefCell::new(None)); let agent_server_store = project.read(cx).agent_server_store().clone(); let agent_display_name = agent_server_store @@ -421,7 +420,7 @@ impl AcpThreadView { workspace.clone(), project.downgrade(), thread_store.clone(), - session_list_state.clone(), + history.downgrade(), prompt_store.clone(), prompt_capabilities.clone(), available_commands.clone(), @@ -447,7 +446,7 @@ impl AcpThreadView { workspace.clone(), project.downgrade(), thread_store.clone(), - session_list_state.clone(), + history.downgrade(), prompt_store.clone(), prompt_capabilities.clone(), available_commands.clone(), @@ -482,6 +481,11 @@ impl AcpThreadView { && project.read(cx).is_local() && agent.clone().downcast::().is_some(); + let recent_history_entries = history.read(cx).get_recent_sessions(3); + let history_subscription = cx.observe(&history, |this, history, cx| { + this.update_recent_history_from_cache(&history, cx); + }); + Self { agent: agent.clone(), agent_server_store, @@ -523,11 +527,9 @@ impl AcpThreadView { available_commands, editor_expanded: false, should_be_following: false, - session_list: None, - session_list_state, - recent_history_entries: Vec::new(), - _recent_history_task: Task::ready(()), - _recent_history_watch_task: None, + recent_history_entries, + history, + _history_subscription: history_subscription, hovered_recent_history_item: None, is_loading_contents: false, _subscriptions: subscriptions, @@ -562,11 +564,7 @@ impl AcpThreadView { self.available_commands.replace(vec![]); self.new_server_version_available.take(); self.message_queue.clear(); - self.session_list = None; - *self.session_list_state.borrow_mut() = None; self.recent_history_entries.clear(); - self._recent_history_watch_task = None; - self._recent_history_task = Task::ready(()); self.turn_tokens = None; self.last_turn_tokens = None; self.turn_started_at = None; @@ -706,7 +704,9 @@ impl AcpThreadView { let connection = thread.read(cx).connection().clone(); let session_id = thread.read(cx).session_id().clone(); let session_list = connection.session_list(cx); - this.set_session_list(session_list, cx); + this.history.update(cx, |history, cx| { + history.set_session_list(session_list, cx); + }); // Check for config options first // Config options take precedence over legacy mode/model selectors @@ -960,10 +960,6 @@ impl AcpThreadView { } } - pub(crate) fn session_list(&self) -> Option> { - self.session_list.clone() - } - pub fn mode_selector(&self) -> Option<&Entity> { match &self.thread_state { ThreadState::Ready { mode_selector, .. } => mode_selector.as_ref(), @@ -1066,12 +1062,13 @@ impl AcpThreadView { return; } - let Some(thread) = self.as_native_thread(cx) else { + let Some(thread) = self.thread() else { return; }; + let Some(session_list) = self - .session_list - .clone() + .as_native_connection(cx) + .and_then(|connection| connection.session_list(cx)) .and_then(|list| list.downcast::()) else { return; @@ -1079,7 +1076,7 @@ impl AcpThreadView { let thread_store = session_list.thread_store().clone(); let client = self.project.read(cx).client(); - let session_id = thread.read(cx).id().clone(); + let session_id = thread.read(cx).session_id().clone(); cx.spawn_in(window, async move |this, cx| { let response = client @@ -4203,59 +4200,18 @@ impl AcpThreadView { ) } - fn set_session_list( + fn update_recent_history_from_cache( &mut self, - session_list: Option>, + history: &Entity, cx: &mut Context, ) { - if let (Some(current), Some(next)) = (&self.session_list, &session_list) - && Rc::ptr_eq(current, next) - { - return; - } - - self.session_list = session_list.clone(); - *self.session_list_state.borrow_mut() = session_list; - self.recent_history_entries.clear(); + self.recent_history_entries = history.read(cx).get_recent_sessions(3); self.hovered_recent_history_item = None; - self.refresh_recent_history(cx); - - self._recent_history_watch_task = self.session_list.as_ref().and_then(|session_list| { - let mut rx = session_list.watch(cx)?; - Some(cx.spawn(async move |this, cx| { - while let Ok(()) = rx.recv().await { - this.update(cx, |this, cx| { - this.refresh_recent_history(cx); - }) - .ok(); - } - })) - }); - } - - fn refresh_recent_history(&mut self, cx: &mut Context) { - let Some(session_list) = self.session_list.clone() else { - return; - }; - - let task = session_list.list_sessions(AgentSessionListRequest::default(), cx); - self._recent_history_task = cx.spawn(async move |this, cx| match task.await { - Ok(response) => { - this.update(cx, |this, cx| { - this.recent_history_entries = response.sessions.into_iter().take(3).collect(); - this.hovered_recent_history_item = None; - cx.notify(); - }) - .ok(); - } - Err(error) => { - log::error!("Failed to load recent session history: {error:#}"); - } - }); + cx.notify(); } fn render_recent_history(&self, cx: &mut Context) -> AnyElement { - let render_history = self.session_list.is_some() && !self.recent_history_entries.is_empty(); + let render_history = !self.recent_history_entries.is_empty(); v_flex() .size_full() @@ -7149,10 +7105,9 @@ impl AcpThreadView { } pub fn delete_history_entry(&mut self, entry: AgentSessionInfo, cx: &mut Context) { - let Some(session_list) = self.session_list.as_ref() else { - return; - }; - let task = session_list.delete_session(&entry.session_id, cx); + let task = self.history.update(cx, |history, cx| { + history.delete_session(&entry.session_id, cx) + }); task.detach_and_log_err(cx); } @@ -7578,7 +7533,9 @@ fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { #[cfg(test)] pub(crate) mod tests { - use acp_thread::{AgentSessionListResponse, StubAgentConnection}; + use acp_thread::{ + AgentSessionList, AgentSessionListRequest, AgentSessionListResponse, StubAgentConnection, + }; use action_log::ActionLog; use agent_client_protocol::SessionId; use editor::MultiBufferOffset; @@ -7658,21 +7615,52 @@ pub(crate) mod tests { } #[gpui::test] - async fn test_recent_history_refreshes_when_session_list_swapped(cx: &mut TestAppContext) { + async fn test_recent_history_refreshes_when_history_cache_updated(cx: &mut TestAppContext) { init_test(cx); - let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; - let session_a = AgentSessionInfo::new(SessionId::new("session-a")); let session_b = AgentSessionInfo::new(SessionId::new("session-b")); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); + // Create history without an initial session list - it will be set after connection + let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx))); + + let thread_view = cx.update(|window, cx| { + cx.new(|cx| { + AcpThreadView::new( + Rc::new(StubAgentServer::default_response()), + None, + None, + workspace.downgrade(), + project, + Some(thread_store), + None, + history.clone(), + false, + window, + cx, + ) + }) + }); + + // Wait for connection to establish + cx.run_until_parked(); + + // Initially empty because StubAgentConnection.session_list() returns None + thread_view.read_with(cx, |view, _cx| { + assert_eq!(view.recent_history_entries.len(), 0); + }); + + // Now set the session list - this simulates external agents providing their history let list_a: Rc = Rc::new(StubSessionList::new(vec![session_a.clone()])); - let list_b: Rc = - Rc::new(StubSessionList::new(vec![session_b.clone()])); - - thread_view.update(cx, |view, cx| { - view.set_session_list(Some(list_a.clone()), cx); + history.update(cx, |history, cx| { + history.set_session_list(Some(list_a), cx); }); cx.run_until_parked(); @@ -7682,14 +7670,13 @@ pub(crate) mod tests { view.recent_history_entries[0].session_id, session_a.session_id ); - - let session_list = view.session_list_state.borrow(); - let session_list = session_list.as_ref().expect("session list should be set"); - assert!(Rc::ptr_eq(session_list, &list_a)); }); - thread_view.update(cx, |view, cx| { - view.set_session_list(Some(list_b.clone()), cx); + // Update to a different session list + let list_b: Rc = + Rc::new(StubSessionList::new(vec![session_b.clone()])); + history.update(cx, |history, cx| { + history.set_session_list(Some(list_b), cx); }); cx.run_until_parked(); @@ -7699,10 +7686,6 @@ pub(crate) mod tests { view.recent_history_entries[0].session_id, session_b.session_id ); - - let session_list = view.session_list_state.borrow(); - let session_list = session_list.as_ref().expect("session list should be set"); - assert!(Rc::ptr_eq(session_list, &list_b)); }); } @@ -7937,6 +7920,7 @@ pub(crate) mod tests { cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); + let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx))); let thread_view = cx.update(|window, cx| { cx.new(|cx| { @@ -7948,6 +7932,7 @@ pub(crate) mod tests { project, Some(thread_store), None, + history, false, window, cx, @@ -8227,6 +8212,7 @@ pub(crate) mod tests { cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); + let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx))); let connection = Rc::new(StubAgentConnection::new()); let thread_view = cx.update(|window, cx| { @@ -8239,6 +8225,7 @@ pub(crate) mod tests { project.clone(), Some(thread_store.clone()), None, + history, false, window, cx, diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 2bb0d36170c22d38dfca130ee74f85767880ede1..1a65ca78ff506df5e8b8457d17c07b2fed6c5f90 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -305,6 +305,7 @@ impl ActiveView { thread_store: Entity, project: Entity, workspace: WeakEntity, + history: Entity, window: &mut Window, cx: &mut App, ) -> Self { @@ -317,6 +318,7 @@ impl ActiveView { project, Some(thread_store), prompt_store, + history, false, window, cx, @@ -429,7 +431,6 @@ pub struct AgentPanel { context_server_registry: Entity, configuration: Option>, configuration_subscription: Option, - history_subscription: Option, active_view: ActiveView, previous_view: Option, new_thread_menu_handle: PopoverMenuHandle, @@ -583,6 +584,7 @@ impl AgentPanel { thread_store.clone(), project.clone(), workspace.clone(), + acp_history.clone(), window, cx, ), @@ -684,7 +686,6 @@ impl AgentPanel { prompt_store, configuration: None, configuration_subscription: None, - history_subscription: None, context_server_registry, previous_view: None, new_thread_menu_handle: PopoverMenuHandle::default(), @@ -732,6 +733,10 @@ impl AgentPanel { &self.thread_store } + pub fn history(&self) -> &Entity { + &self.acp_history + } + pub fn open_thread( &mut self, thread: AgentSessionInfo, @@ -1558,21 +1563,13 @@ impl AgentPanel { project, thread_store, self.prompt_store.clone(), + self.acp_history.clone(), !loading, window, cx, ) }); - let acp_history = self.acp_history.clone(); - self.history_subscription = Some(cx.observe(&thread_view, move |_, thread_view, cx| { - if let Some(session_list) = thread_view.read(cx).session_list() { - acp_history.update(cx, |history, cx| { - history.set_session_list(Some(session_list), cx); - }); - } - })); - self.set_active_view( ActiveView::ExternalAgentThread { thread_view }, !loading, @@ -2947,13 +2944,16 @@ impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist { return; }; let project = workspace.read(cx).project().downgrade(); - let thread_store = panel.read(cx).thread_store().clone(); + let panel = panel.read(cx); + let thread_store = panel.thread_store().clone(); + let history = panel.history().downgrade(); assistant.assist( prompt_editor, self.workspace.clone(), project, thread_store, None, + history, initial_prompt, window, cx, diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index ad3bfd9c01c844132c2a1b344d58f01a924c4d88..b5f08ec0aeee597f89654debe1dbc2838f8ac750 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -1,13 +1,11 @@ -use std::cell::RefCell; use std::cmp::Reverse; use std::ops::Range; use std::path::PathBuf; -use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::AtomicBool; -use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest, MentionUri}; -use agent::ThreadStore; +use crate::acp::AcpThreadHistory; +use acp_thread::{AgentSessionInfo, MentionUri}; use anyhow::Result; use editor::{ CompletionProvider, Editor, ExcerptId, code_context_menus::COMPLETION_MENU_MAX_WIDTH, @@ -196,8 +194,7 @@ pub struct PromptCompletionProvider { source: Arc, editor: WeakEntity, mention_set: Entity, - thread_store: Option>, - session_list: Rc>>>, + history: WeakEntity, prompt_store: Option>, workspace: WeakEntity, } @@ -207,8 +204,7 @@ impl PromptCompletionProvider { source: T, editor: WeakEntity, mention_set: Entity, - thread_store: Option>, - session_list: Rc>>>, + history: WeakEntity, prompt_store: Option>, workspace: WeakEntity, ) -> Self { @@ -217,8 +213,7 @@ impl PromptCompletionProvider { editor, mention_set, workspace, - thread_store, - session_list, + history, prompt_store, } } @@ -653,25 +648,12 @@ impl PromptCompletionProvider { } Some(PromptContextType::Thread) => { - if let Some(session_list) = self.session_list.borrow().clone() { - let search_sessions_task = - search_sessions(query, cancellation_flag, session_list, cx); + if let Some(history) = self.history.upgrade() { + let sessions = history.read(cx).sessions().to_vec(); + let search_task = + filter_sessions_by_query(query, cancellation_flag, sessions, cx); cx.spawn(async move |_cx| { - search_sessions_task - .await - .into_iter() - .map(Match::Thread) - .collect() - }) - } else if let Some(thread_store) = self.thread_store.as_ref() { - let search_threads_task = - search_threads(query, cancellation_flag, thread_store, cx); - cx.background_spawn(async move { - search_threads_task - .await - .into_iter() - .map(Match::Thread) - .collect() + search_task.await.into_iter().map(Match::Thread).collect() }) } else { Task::ready(Vec::new()) @@ -835,19 +817,12 @@ impl PromptCompletionProvider { return Task::ready(recent); } - if let Some(session_list) = self.session_list.borrow().clone() { - let task = session_list.list_sessions(AgentSessionListRequest::default(), cx); - return cx.spawn(async move |_cx| { - let sessions = match task.await { - Ok(response) => response.sessions, - Err(error) => { - log::error!("Failed to load recent sessions: {error:#}"); - return recent; - } - }; - - const RECENT_COUNT: usize = 2; - let threads = sessions + if let Some(history) = self.history.upgrade() { + const RECENT_COUNT: usize = 2; + recent.extend( + history + .read(cx) + .sessions() .into_iter() .filter(|session| { let uri = MentionUri::Thread { @@ -857,33 +832,11 @@ impl PromptCompletionProvider { !mentions.contains(&uri) }) .take(RECENT_COUNT) - .collect::>(); - - recent.extend(threads.into_iter().map(Match::RecentThread)); - recent - }); - } - - let Some(thread_store) = self.thread_store.as_ref() else { + .cloned() + .map(Match::RecentThread), + ); return Task::ready(recent); - }; - - const RECENT_COUNT: usize = 2; - let threads = thread_store - .read(cx) - .entries() - .map(thread_metadata_to_session_info) - .filter(|thread| { - let uri = MentionUri::Thread { - id: thread.session_id.clone(), - name: session_title(thread).to_string(), - }; - !mentions.contains(&uri) - }) - .take(RECENT_COUNT) - .collect::>(); - - recent.extend(threads.into_iter().map(Match::RecentThread)); + } Task::ready(recent) } @@ -1608,50 +1561,21 @@ pub(crate) fn search_symbols( }) } -pub(crate) fn search_threads( +fn filter_sessions_by_query( query: String, cancellation_flag: Arc, - thread_store: &Entity, + sessions: Vec, cx: &mut App, ) -> Task> { - let sessions = thread_store - .read(cx) - .entries() - .map(thread_metadata_to_session_info) - .collect::>(); if query.is_empty() { return Task::ready(sessions); } - let executor = cx.background_executor().clone(); cx.background_spawn(async move { filter_sessions(query, cancellation_flag, sessions, executor).await }) } -pub(crate) fn search_sessions( - query: String, - cancellation_flag: Arc, - session_list: Rc, - cx: &mut App, -) -> Task> { - let task = session_list.list_sessions(AgentSessionListRequest::default(), cx); - let executor = cx.background_executor().clone(); - cx.spawn(async move |_cx| { - let sessions = match task.await { - Ok(response) => response.sessions, - Err(error) => { - log::error!("Failed to list sessions: {error:#}"); - return Vec::new(); - } - }; - if query.is_empty() { - return sessions; - } - filter_sessions(query, cancellation_flag, sessions, executor).await - }) -} - async fn filter_sessions( query: String, cancellation_flag: Arc, @@ -1681,16 +1605,6 @@ async fn filter_sessions( .collect() } -fn thread_metadata_to_session_info(entry: agent::DbThreadMetadata) -> AgentSessionInfo { - AgentSessionInfo { - session_id: entry.id, - cwd: None, - title: Some(entry.title), - updated_at: Some(entry.updated_at), - meta: None, - } -} - pub(crate) fn search_rules( query: String, cancellation_flag: Arc, @@ -1815,9 +1729,7 @@ fn selection_ranges( #[cfg(test)] mod tests { use super::*; - use acp_thread::AgentSessionListResponse; use gpui::TestAppContext; - use std::{any::Any, rc::Rc}; #[test] fn test_prompt_completion_parse() { @@ -2059,41 +1971,20 @@ mod tests { } #[gpui::test] - async fn test_search_sessions_filters_results(cx: &mut TestAppContext) { - #[derive(Clone)] - struct StubSessionList { - sessions: Vec, - } - - impl AgentSessionList for StubSessionList { - fn list_sessions( - &self, - _request: AgentSessionListRequest, - _cx: &mut App, - ) -> Task> { - Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone()))) - } - - fn into_any(self: Rc) -> Rc { - self - } - } - + async fn test_filter_sessions_by_query(cx: &mut TestAppContext) { let mut alpha = AgentSessionInfo::new("session-alpha"); alpha.title = Some("Alpha Session".into()); let mut beta = AgentSessionInfo::new("session-beta"); beta.title = Some("Beta Session".into()); - let session_list: Rc = Rc::new(StubSessionList { - sessions: vec![alpha.clone(), beta], - }); + let sessions = vec![alpha.clone(), beta]; let task = { let mut app = cx.app.borrow_mut(); - search_sessions( + filter_sessions_by_query( "Alpha".into(), Arc::new(AtomicBool::default()), - session_list, + sessions, &mut app, ) }; diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index 726b31f084b119fad5f9f2d5dc7ee75071fd63a6..110b4b86675593001e7034fa88178d0b5092d41d 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -7,6 +7,7 @@ use std::rc::Rc; use std::sync::Arc; use uuid::Uuid; +use crate::acp::AcpThreadHistory; use crate::context::load_context; use crate::mention_set::MentionSet; use crate::{ @@ -264,6 +265,7 @@ impl InlineAssistant { let prompt_store = agent_panel.prompt_store().as_ref().cloned(); let thread_store = agent_panel.thread_store().clone(); + let history = agent_panel.history().downgrade(); let handle_assist = |window: &mut Window, cx: &mut Context| match inline_assist_target { @@ -275,6 +277,7 @@ impl InlineAssistant { workspace.project().downgrade(), thread_store, prompt_store, + history, action.prompt.clone(), window, cx, @@ -289,6 +292,7 @@ impl InlineAssistant { workspace.project().downgrade(), thread_store, prompt_store, + history, action.prompt.clone(), window, cx, @@ -470,6 +474,7 @@ impl InlineAssistant { project: WeakEntity, thread_store: Entity, prompt_store: Option>, + history: WeakEntity, initial_prompt: Option, window: &mut Window, codegen_ranges: &[Range], @@ -516,6 +521,7 @@ impl InlineAssistant { self.fs.clone(), thread_store.clone(), prompt_store.clone(), + history.clone(), project.clone(), workspace.clone(), window, @@ -607,6 +613,7 @@ impl InlineAssistant { project: WeakEntity, thread_store: Entity, prompt_store: Option>, + history: WeakEntity, initial_prompt: Option, window: &mut Window, cx: &mut App, @@ -625,6 +632,7 @@ impl InlineAssistant { project, thread_store, prompt_store, + history, initial_prompt, window, &codegen_ranges, @@ -650,6 +658,7 @@ impl InlineAssistant { workspace: Entity, thread_store: Entity, prompt_store: Option>, + history: WeakEntity, window: &mut Window, cx: &mut App, ) -> InlineAssistId { @@ -669,6 +678,7 @@ impl InlineAssistant { project, thread_store, prompt_store, + history, Some(initial_prompt), window, &[range], @@ -1937,16 +1947,13 @@ impl CodeActionProvider for AssistantCodeActionProvider { let prompt_store = PromptStore::global(cx); window.spawn(cx, async move |cx| { let workspace = workspace.upgrade().context("workspace was released")?; - let thread_store = cx.update(|_window, cx| { - anyhow::Ok( - workspace - .read(cx) - .panel::(cx) - .context("missing agent panel")? - .read(cx) - .thread_store() - .clone(), - ) + let (thread_store, history) = cx.update(|_window, cx| { + let panel = workspace + .read(cx) + .panel::(cx) + .context("missing agent panel")? + .read(cx); + anyhow::Ok((panel.thread_store().clone(), panel.history().downgrade())) })??; let editor = editor.upgrade().context("editor was released")?; let range = editor @@ -1992,6 +1999,7 @@ impl CodeActionProvider for AssistantCodeActionProvider { workspace, thread_store, prompt_store, + history, window, cx, ); @@ -2114,7 +2122,7 @@ pub mod test { setup(cx); - let (_editor, buffer) = cx.update(|window, cx| { + let (_editor, buffer, _history) = cx.update(|window, cx| { let buffer = cx.new(|cx| Buffer::local("", cx)); let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); let editor = cx.new(|cx| Editor::for_multibuffer(multibuffer, None, window, cx)); @@ -2131,6 +2139,7 @@ pub mod test { }); let thread_store = cx.new(|cx| ThreadStore::new(cx)); + let history = cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)); // Add editor to workspace workspace.update(cx, |workspace, cx| { @@ -2146,6 +2155,7 @@ pub mod test { project.downgrade(), thread_store, None, + history.downgrade(), Some(prompt), window, cx, @@ -2155,7 +2165,7 @@ pub mod test { inline_assistant.start_assist(assist_id, window, cx); }); - (editor, buffer) + (editor, buffer, history) }); cx.run_until_parked(); diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index a389fd132745e78540d41427e8ce9265af799e35..6de856e240b466169d4e33f22dfd78c999394030 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -1,3 +1,4 @@ +use crate::acp::AcpThreadHistory; use agent::ThreadStore; use collections::{HashMap, VecDeque}; use editor::actions::Paste; @@ -19,7 +20,6 @@ use parking_lot::Mutex; use project::Project; use prompt_store::PromptStore; use settings::Settings; -use std::cell::RefCell; use std::cmp; use std::ops::Range; use std::rc::Rc; @@ -61,7 +61,7 @@ pub struct PromptEditor { pub editor: Entity, mode: PromptEditorMode, mention_set: Entity, - thread_store: Entity, + history: WeakEntity, prompt_store: Option>, workspace: WeakEntity, model_selector: Entity, @@ -332,8 +332,7 @@ impl PromptEditor { PromptEditorCompletionProviderDelegate, cx.weak_entity(), self.mention_set.clone(), - Some(self.thread_store.clone()), - Rc::new(RefCell::new(None)), + self.history.clone(), self.prompt_store.clone(), self.workspace.clone(), )))); @@ -1213,6 +1212,7 @@ impl PromptEditor { fs: Arc, thread_store: Entity, prompt_store: Option>, + history: WeakEntity, project: WeakEntity, workspace: WeakEntity, window: &mut Window, @@ -1259,7 +1259,7 @@ impl PromptEditor { let mut this: PromptEditor = PromptEditor { editor: prompt_editor.clone(), mention_set, - thread_store, + history, prompt_store, workspace, model_selector: cx.new(|cx| { @@ -1371,6 +1371,7 @@ impl PromptEditor { fs: Arc, thread_store: Entity, prompt_store: Option>, + history: WeakEntity, project: WeakEntity, workspace: WeakEntity, window: &mut Window, @@ -1412,7 +1413,7 @@ impl PromptEditor { let mut this = Self { editor: prompt_editor.clone(), mention_set, - thread_store, + history, prompt_store, workspace, model_selector: cx.new(|cx| { diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index d8e18bd52ba89e7fbf4f3391e5c7ca3f042b289e..7804b942e6c60d2306ccdc3099e3f8e0be5f865d 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -1,4 +1,5 @@ use crate::{ + acp::AcpThreadHistory, context::load_context, inline_prompt_editor::{ CodegenStatus, PromptEditor, PromptEditorEvent, TerminalInlineAssistId, @@ -63,6 +64,7 @@ impl TerminalInlineAssistant { project: WeakEntity, thread_store: Entity, prompt_store: Option>, + history: WeakEntity, initial_prompt: Option, window: &mut Window, cx: &mut App, @@ -88,6 +90,7 @@ impl TerminalInlineAssistant { self.fs.clone(), thread_store.clone(), prompt_store.clone(), + history, project.clone(), workspace.clone(), window, diff --git a/crates/agent_ui_v2/Cargo.toml b/crates/agent_ui_v2/Cargo.toml index 18e5bd06f8e49195dc84a66168f52007afb11931..368fb8f271ab0ae4272b5c674c1a412fabeb77d4 100644 --- a/crates/agent_ui_v2/Cargo.toml +++ b/crates/agent_ui_v2/Cargo.toml @@ -24,23 +24,16 @@ agent_servers.workspace = true agent_settings.workspace = true agent_ui.workspace = true anyhow.workspace = true -chrono.workspace = true db.workspace = true -editor.workspace = true feature_flags.workspace = true fs.workspace = true -fuzzy.workspace = true gpui.workspace = true log.workspace = true -menu.workspace = true project.workspace = true prompt_store.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true -text.workspace = true -time.workspace = true -time_format.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/agent_ui_v2/src/agent_thread_pane.rs b/crates/agent_ui_v2/src/agent_thread_pane.rs index 60d8fdccaeb3db3302e912728ff1a1d3c3eb3464..c4d644af6f0af9815270d14735aeb33ef4b62a95 100644 --- a/crates/agent_ui_v2/src/agent_thread_pane.rs +++ b/crates/agent_ui_v2/src/agent_thread_pane.rs @@ -3,7 +3,7 @@ use agent::{NativeAgentServer, ThreadStore}; use agent_client_protocol as acp; use agent_servers::AgentServer; use agent_settings::AgentSettings; -use agent_ui::acp::AcpThreadView; +use agent_ui::acp::{AcpThreadHistory, AcpThreadView}; use fs::Fs; use gpui::{ Entity, EventEmitter, Focusable, Pixels, SharedString, Subscription, WeakEntity, prelude::*, @@ -62,10 +62,15 @@ pub struct AgentThreadPane { width: Option, thread_view: Option, workspace: WeakEntity, + history: Entity, } impl AgentThreadPane { - pub fn new(workspace: WeakEntity, cx: &mut ui::Context) -> Self { + pub fn new( + workspace: WeakEntity, + history: Entity, + cx: &mut ui::Context, + ) -> Self { let focus_handle = cx.focus_handle(); Self { focus_handle, @@ -73,6 +78,7 @@ impl AgentThreadPane { width: None, thread_view: None, workspace, + history, } } @@ -104,6 +110,7 @@ impl AgentThreadPane { let agent: Rc = Rc::new(NativeAgentServer::new(fs, thread_store.clone())); + let history = self.history.clone(); let thread_view = cx.new(|cx| { AcpThreadView::new( agent, @@ -113,6 +120,7 @@ impl AgentThreadPane { project, Some(thread_store), prompt_store, + history, true, window, cx, diff --git a/crates/agent_ui_v2/src/agent_ui_v2.rs b/crates/agent_ui_v2/src/agent_ui_v2.rs index 92a4144e304e9afbdcdde54623a3bbf3c65b8746..eb91b44f2524983fc27fb976ac1ef05ae356ae13 100644 --- a/crates/agent_ui_v2/src/agent_ui_v2.rs +++ b/crates/agent_ui_v2/src/agent_ui_v2.rs @@ -1,4 +1,3 @@ mod agent_thread_pane; -mod thread_history; pub mod agents_panel; diff --git a/crates/agent_ui_v2/src/agents_panel.rs b/crates/agent_ui_v2/src/agents_panel.rs index 8043f92738316d5e7e4da41f8c6872689d2b4eeb..a9c65dd5abe70f75bf8e4f648287f9a823f31ea3 100644 --- a/crates/agent_ui_v2/src/agents_panel.rs +++ b/crates/agent_ui_v2/src/agents_panel.rs @@ -27,7 +27,7 @@ use workspace::{ use crate::agent_thread_pane::{ AgentThreadPane, AgentsUtilityPaneEvent, SerializedAgentThreadPane, SerializedHistoryEntryId, }; -use crate::thread_history::{AcpThreadHistory, ThreadHistoryEvent}; +use agent_ui::acp::{AcpThreadHistory, ThreadHistoryEvent}; const AGENTS_PANEL_KEY: &str = "agents_panel"; @@ -310,9 +310,10 @@ impl AgentsPanel { let project = self.project.clone(); let thread_store = self.thread_store.clone(); let prompt_store = self.prompt_store.clone(); + let history = self.history.clone(); let agent_thread_pane = cx.new(|cx| { - let mut pane = AgentThreadPane::new(workspace.clone(), cx); + let mut pane = AgentThreadPane::new(workspace.clone(), history, cx); pane.open_thread( entry, fs, diff --git a/crates/agent_ui_v2/src/thread_history.rs b/crates/agent_ui_v2/src/thread_history.rs deleted file mode 100644 index 90f707eb56657336a76bc5552526994365af2164..0000000000000000000000000000000000000000 --- a/crates/agent_ui_v2/src/thread_history.rs +++ /dev/null @@ -1,868 +0,0 @@ -use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest}; -use agent_client_protocol as acp; -use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; -use editor::{Editor, EditorEvent}; -use fuzzy::StringMatchCandidate; -use gpui::{ - App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task, - UniformListScrollHandle, Window, actions, uniform_list, -}; -use std::{fmt::Display, ops::Range, rc::Rc}; -use text::Bias; -use time::{OffsetDateTime, UtcOffset}; -use ui::{ - HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, WithScrollbar, - prelude::*, -}; - -const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread"); - -fn thread_title(entry: &AgentSessionInfo) -> &SharedString { - entry - .title - .as_ref() - .filter(|title| !title.is_empty()) - .unwrap_or(DEFAULT_TITLE) -} - -actions!( - agents, - [ - /// Removes all thread history. - RemoveHistory, - /// Removes the currently selected thread. - RemoveSelectedThread, - ] -); - -pub struct AcpThreadHistory { - session_list: Option>, - sessions: Vec, - scroll_handle: UniformListScrollHandle, - selected_index: usize, - hovered_index: Option, - search_editor: Entity, - search_query: SharedString, - visible_items: Vec, - local_timezone: UtcOffset, - confirming_delete_history: bool, - _update_task: Task<()>, - _watch_task: Option>, - _subscriptions: Vec, -} - -enum ListItemType { - BucketSeparator(TimeBucket), - Entry { - entry: AgentSessionInfo, - format: EntryTimeFormat, - }, - SearchResult { - entry: AgentSessionInfo, - positions: Vec, - }, -} - -impl ListItemType { - fn history_entry(&self) -> Option<&AgentSessionInfo> { - match self { - ListItemType::Entry { entry, .. } => Some(entry), - ListItemType::SearchResult { entry, .. } => Some(entry), - _ => None, - } - } -} - -#[allow(dead_code)] -pub enum ThreadHistoryEvent { - Open(AgentSessionInfo), -} - -impl EventEmitter for AcpThreadHistory {} - -impl AcpThreadHistory { - pub fn new( - session_list: Option>, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let search_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Search threads...", window, cx); - editor - }); - - let search_editor_subscription = - cx.subscribe(&search_editor, |this, search_editor, event, cx| { - if let EditorEvent::BufferEdited = event { - let query = search_editor.read(cx).text(cx); - if this.search_query != query { - this.search_query = query.into(); - this.update_visible_items(false, cx); - } - } - }); - - let scroll_handle = UniformListScrollHandle::default(); - - let mut this = Self { - session_list: None, - sessions: Vec::new(), - scroll_handle, - selected_index: 0, - hovered_index: None, - visible_items: Default::default(), - search_editor, - local_timezone: UtcOffset::from_whole_seconds( - chrono::Local::now().offset().local_minus_utc(), - ) - .unwrap(), - search_query: SharedString::default(), - confirming_delete_history: false, - _subscriptions: vec![search_editor_subscription], - _update_task: Task::ready(()), - _watch_task: None, - }; - this.set_session_list(session_list, cx); - this - } - - fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { - let entries = self.sessions.clone(); - let new_list_items = if self.search_query.is_empty() { - self.add_list_separators(entries, cx) - } else { - self.filter_search_results(entries, cx) - }; - let selected_history_entry = if preserve_selected_item { - self.selected_history_entry().cloned() - } else { - None - }; - - self._update_task = cx.spawn(async move |this, cx| { - let new_visible_items = new_list_items.await; - this.update(cx, |this, cx| { - let new_selected_index = if let Some(history_entry) = selected_history_entry { - new_visible_items - .iter() - .position(|visible_entry| { - visible_entry - .history_entry() - .is_some_and(|entry| entry.session_id == history_entry.session_id) - }) - .unwrap_or(0) - } else { - 0 - }; - - this.visible_items = new_visible_items; - this.set_selected_index(new_selected_index, Bias::Right, cx); - cx.notify(); - }) - .ok(); - }); - } - - pub(crate) fn set_session_list( - &mut self, - session_list: Option>, - cx: &mut Context, - ) { - if let (Some(current), Some(next)) = (&self.session_list, &session_list) - && Rc::ptr_eq(current, next) - { - return; - } - - self.session_list = session_list; - self.sessions.clear(); - self.visible_items.clear(); - self.selected_index = 0; - self.refresh_sessions(false, cx); - - self._watch_task = self.session_list.as_ref().and_then(|session_list| { - let mut rx = session_list.watch(cx)?; - Some(cx.spawn(async move |this, cx| { - while let Ok(()) = rx.recv().await { - this.update(cx, |this, cx| { - this.refresh_sessions(true, cx); - }) - .ok(); - } - })) - }); - } - - fn refresh_sessions(&mut self, preserve_selected_item: bool, cx: &mut Context) { - let Some(session_list) = self.session_list.clone() else { - self.update_visible_items(preserve_selected_item, cx); - return; - }; - - self._update_task = cx.spawn(async move |this, cx| { - let mut cursor: Option = None; - let mut is_first_page = true; - - loop { - let request = AgentSessionListRequest { - cursor: cursor.clone(), - ..Default::default() - }; - let task = cx.update(|cx| session_list.list_sessions(request, cx)); - let response = match task.await { - Ok(response) => response, - Err(error) => { - log::error!("Failed to load session history: {error:#}"); - return; - } - }; - - let acp_thread::AgentSessionListResponse { - sessions: page_sessions, - next_cursor, - .. - } = response; - - this.update(cx, |this, cx| { - if is_first_page { - this.sessions = page_sessions; - } else { - this.sessions.extend(page_sessions); - } - this.update_visible_items(preserve_selected_item, cx); - }) - .ok(); - - is_first_page = false; - match next_cursor { - Some(next_cursor) => { - if cursor.as_ref() == Some(&next_cursor) { - log::warn!( - "Session list pagination returned the same cursor; stopping to avoid a loop." - ); - break; - } - cursor = Some(next_cursor); - } - None => break, - } - } - }); - } - - pub(crate) fn is_empty(&self) -> bool { - self.sessions.is_empty() - } - - pub(crate) fn session_for_id(&self, session_id: &acp::SessionId) -> Option { - self.sessions - .iter() - .find(|entry| &entry.session_id == session_id) - .cloned() - } - - #[allow(dead_code)] - pub(crate) fn sessions(&self) -> &[AgentSessionInfo] { - &self.sessions - } - - fn add_list_separators( - &self, - entries: Vec, - cx: &App, - ) -> Task> { - cx.background_spawn(async move { - let mut items = Vec::with_capacity(entries.len() + 1); - let mut bucket = None; - let today = Local::now().naive_local().date(); - - for entry in entries.into_iter() { - let entry_bucket = entry - .updated_at - .map(|timestamp| { - let entry_date = timestamp.with_timezone(&Local).naive_local().date(); - TimeBucket::from_dates(today, entry_date) - }) - .unwrap_or(TimeBucket::All); - - if Some(entry_bucket) != bucket { - bucket = Some(entry_bucket); - items.push(ListItemType::BucketSeparator(entry_bucket)); - } - - items.push(ListItemType::Entry { - entry, - format: entry_bucket.into(), - }); - } - items - }) - } - - fn filter_search_results( - &self, - entries: Vec, - cx: &App, - ) -> Task> { - let query = self.search_query.clone(); - cx.background_spawn({ - let executor = cx.background_executor().clone(); - async move { - let mut candidates = Vec::with_capacity(entries.len()); - - for (idx, entry) in entries.iter().enumerate() { - candidates.push(StringMatchCandidate::new(idx, thread_title(entry))); - } - - const MAX_MATCHES: usize = 100; - - let matches = fuzzy::match_strings( - &candidates, - &query, - false, - true, - MAX_MATCHES, - &Default::default(), - executor, - ) - .await; - - matches - .into_iter() - .map(|search_match| ListItemType::SearchResult { - entry: entries[search_match.candidate_id].clone(), - positions: search_match.positions, - }) - .collect() - } - }) - } - - fn search_produced_no_matches(&self) -> bool { - self.visible_items.is_empty() && !self.search_query.is_empty() - } - - fn selected_history_entry(&self) -> Option<&AgentSessionInfo> { - self.get_history_entry(self.selected_index) - } - - fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> { - self.visible_items.get(visible_items_ix)?.history_entry() - } - - fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context) { - if self.visible_items.is_empty() { - self.selected_index = 0; - return; - } - while matches!( - self.visible_items.get(index), - None | Some(ListItemType::BucketSeparator(..)) - ) { - index = match bias { - Bias::Left => { - if index == 0 { - self.visible_items.len() - 1 - } else { - index - 1 - } - } - Bias::Right => { - if index >= self.visible_items.len() - 1 { - 0 - } else { - index + 1 - } - } - }; - } - self.selected_index = index; - self.scroll_handle - .scroll_to_item(index, ScrollStrategy::Top); - cx.notify() - } - - pub fn select_previous( - &mut self, - _: &menu::SelectPrevious, - _window: &mut Window, - cx: &mut Context, - ) { - if self.selected_index == 0 { - self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); - } else { - self.set_selected_index(self.selected_index - 1, Bias::Left, cx); - } - } - - pub fn select_next( - &mut self, - _: &menu::SelectNext, - _window: &mut Window, - cx: &mut Context, - ) { - if self.selected_index == self.visible_items.len() - 1 { - self.set_selected_index(0, Bias::Right, cx); - } else { - self.set_selected_index(self.selected_index + 1, Bias::Right, cx); - } - } - - fn select_first( - &mut self, - _: &menu::SelectFirst, - _window: &mut Window, - cx: &mut Context, - ) { - self.set_selected_index(0, Bias::Right, cx); - } - - fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { - self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); - } - - fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { - self.confirm_entry(self.selected_index, cx); - } - - fn confirm_entry(&mut self, ix: usize, cx: &mut Context) { - let Some(entry) = self.get_history_entry(ix) else { - return; - }; - cx.emit(ThreadHistoryEvent::Open(entry.clone())); - } - - fn remove_selected_thread( - &mut self, - _: &RemoveSelectedThread, - _window: &mut Window, - cx: &mut Context, - ) { - self.remove_thread(self.selected_index, cx) - } - - fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) { - let Some(entry) = self.get_history_entry(visible_item_ix) else { - return; - }; - let Some(session_list) = self.session_list.as_ref() else { - 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); - } - self.confirming_delete_history = false; - cx.notify(); - } - - fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { - self.confirming_delete_history = true; - cx.notify(); - } - - fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { - self.confirming_delete_history = false; - cx.notify(); - } - - fn render_list_items( - &mut self, - range: Range, - _window: &mut Window, - cx: &mut Context, - ) -> Vec { - self.visible_items - .get(range.clone()) - .into_iter() - .flatten() - .enumerate() - .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx)) - .collect() - } - - fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context) -> AnyElement { - match item { - ListItemType::Entry { entry, format } => self - .render_history_entry(entry, *format, ix, Vec::default(), cx) - .into_any(), - ListItemType::SearchResult { entry, positions } => self.render_history_entry( - entry, - EntryTimeFormat::DateAndTime, - ix, - positions.clone(), - cx, - ), - ListItemType::BucketSeparator(bucket) => div() - .px(DynamicSpacing::Base06.rems(cx)) - .pt_2() - .pb_1() - .child( - Label::new(bucket.to_string()) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .into_any_element(), - } - } - - fn render_history_entry( - &self, - entry: &AgentSessionInfo, - format: EntryTimeFormat, - ix: usize, - highlight_positions: Vec, - cx: &Context, - ) -> AnyElement { - let selected = ix == self.selected_index; - let hovered = Some(ix) == self.hovered_index; - let display_text = match (format, entry.updated_at) { - (EntryTimeFormat::DateAndTime, Some(entry_time)) => { - let now = Utc::now(); - let duration = now.signed_duration_since(entry_time); - let days = duration.num_days(); - - format!("{}d", days) - } - (EntryTimeFormat::TimeOnly, Some(entry_time)) => { - format.format_timestamp(entry_time.timestamp(), self.local_timezone) - } - (_, None) => "—".to_string(), - }; - - let title = thread_title(entry).clone(); - let full_date = entry - .updated_at - .map(|time| { - EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone) - }) - .unwrap_or_else(|| "Unknown".to_string()); - - h_flex() - .w_full() - .pb_1() - .child( - ListItem::new(ix) - .rounded() - .toggle_state(selected) - .spacing(ListItemSpacing::Sparse) - .start_slot( - h_flex() - .w_full() - .gap_2() - .justify_between() - .child( - HighlightedLabel::new(thread_title(entry), highlight_positions) - .size(LabelSize::Small) - .truncate(), - ) - .child( - Label::new(display_text) - .color(Color::Muted) - .size(LabelSize::XSmall), - ), - ) - .tooltip(move |_, cx| { - Tooltip::with_meta(title.clone(), None, full_date.clone(), cx) - }) - .on_hover(cx.listener(move |this, is_hovered, _window, cx| { - if *is_hovered { - this.hovered_index = Some(ix); - } else if this.hovered_index == Some(ix) { - this.hovered_index = None; - } - - cx.notify(); - })) - .end_slot::(if hovered { - Some( - IconButton::new("delete", IconName::Trash) - .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .tooltip(move |_window, cx| { - Tooltip::for_action("Delete", &RemoveSelectedThread, cx) - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.remove_thread(ix, cx); - cx.stop_propagation() - })), - ) - } else { - None - }) - .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))), - ) - .into_any_element() - } -} - -impl Focusable for AcpThreadHistory { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.search_editor.focus_handle(cx) - } -} - -impl Render for AcpThreadHistory { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let has_no_history = self.is_empty(); - - v_flex() - .key_context("ThreadHistory") - .size_full() - .bg(cx.theme().colors().panel_background) - .on_action(cx.listener(Self::select_previous)) - .on_action(cx.listener(Self::select_next)) - .on_action(cx.listener(Self::select_first)) - .on_action(cx.listener(Self::select_last)) - .on_action(cx.listener(Self::confirm)) - .on_action(cx.listener(Self::remove_selected_thread)) - .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| { - this.remove_history(window, cx); - })) - .child( - h_flex() - .h(Tab::container_height(cx)) - .w_full() - .py_1() - .px_2() - .gap_2() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - Icon::new(IconName::MagnifyingGlass) - .color(Color::Muted) - .size(IconSize::Small), - ) - .child(self.search_editor.clone()), - ) - .child({ - let view = v_flex() - .id("list-container") - .relative() - .overflow_hidden() - .flex_grow(); - - if has_no_history { - view.justify_center().items_center().child( - Label::new("You don't have any past threads yet.") - .size(LabelSize::Small) - .color(Color::Muted), - ) - } else if self.search_produced_no_matches() { - view.justify_center() - .items_center() - .child(Label::new("No threads match your search.").size(LabelSize::Small)) - } else { - view.child( - uniform_list( - "thread-history", - self.visible_items.len(), - cx.processor(|this, range: Range, window, cx| { - this.render_list_items(range, window, cx) - }), - ) - .p_1() - .pr_4() - .track_scroll(&self.scroll_handle) - .flex_grow(), - ) - .vertical_scrollbar_for(&self.scroll_handle, window, cx) - } - }) - .when(!has_no_history, |this| { - this.child( - h_flex() - .p_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .when(!self.confirming_delete_history, |this| { - this.child( - Button::new("delete_history", "Delete All History") - .full_width() - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - this.prompt_delete_history(window, cx); - })), - ) - }) - .when(self.confirming_delete_history, |this| { - this.w_full() - .gap_2() - .flex_wrap() - .justify_between() - .child( - h_flex() - .flex_wrap() - .gap_1() - .child( - Label::new("Delete all threads?") - .size(LabelSize::Small), - ) - .child( - Label::new("You won't be able to recover them later.") - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .child( - h_flex() - .gap_1() - .child( - Button::new("cancel_delete", "Cancel") - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - this.cancel_delete_history(window, cx); - })), - ) - .child( - Button::new("confirm_delete", "Delete") - .style(ButtonStyle::Tinted(ui::TintColor::Error)) - .color(Color::Error) - .label_size(LabelSize::Small) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action( - Box::new(RemoveHistory), - cx, - ); - })), - ), - ) - }), - ) - }) - } -} - -#[derive(Clone, Copy)] -pub enum EntryTimeFormat { - DateAndTime, - TimeOnly, -} - -impl EntryTimeFormat { - fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String { - let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap(); - - match self { - EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp( - timestamp, - OffsetDateTime::now_utc(), - timezone, - time_format::TimestampFormat::EnhancedAbsolute, - ), - EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)), - } - } -} - -impl From for EntryTimeFormat { - fn from(bucket: TimeBucket) -> Self { - match bucket { - TimeBucket::Today => EntryTimeFormat::TimeOnly, - TimeBucket::Yesterday => EntryTimeFormat::TimeOnly, - TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime, - TimeBucket::PastWeek => EntryTimeFormat::DateAndTime, - TimeBucket::All => EntryTimeFormat::DateAndTime, - } - } -} - -#[derive(PartialEq, Eq, Clone, Copy, Debug)] -enum TimeBucket { - Today, - Yesterday, - ThisWeek, - PastWeek, - All, -} - -impl TimeBucket { - fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { - if date == reference { - return TimeBucket::Today; - } - - if date == reference - TimeDelta::days(1) { - return TimeBucket::Yesterday; - } - - let week = date.iso_week(); - - if reference.iso_week() == week { - return TimeBucket::ThisWeek; - } - - let last_week = (reference - TimeDelta::days(7)).iso_week(); - - if week == last_week { - return TimeBucket::PastWeek; - } - - TimeBucket::All - } -} - -impl Display for TimeBucket { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TimeBucket::Today => write!(f, "Today"), - TimeBucket::Yesterday => write!(f, "Yesterday"), - TimeBucket::ThisWeek => write!(f, "This Week"), - TimeBucket::PastWeek => write!(f, "Past Week"), - TimeBucket::All => write!(f, "All"), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::NaiveDate; - - #[test] - fn test_time_bucket_from_dates() { - let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap(); - - let date = today; - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today); - - let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday); - - let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); - - let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); - - let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); - - let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); - - // All: not in this week or last week - let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All); - - // Test year boundary cases - let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); - - let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap(); - assert_eq!( - TimeBucket::from_dates(new_year, date), - TimeBucket::Yesterday - ); - - let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap(); - assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek); - } -}