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); - } -}