From a94157714a58ecad9b242c1ffe1c32473fff07ad Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 9 Jan 2026 18:13:00 +0100 Subject: [PATCH] agent: Split up Agent and Text Thread History (#46457) Scopes the history to the individual panes and paves the way for history per external agent. Release Notes: - N/A --- Cargo.lock | 3 +- assets/keymaps/default-linux.json | 6 - assets/keymaps/default-macos.json | 6 - assets/keymaps/default-windows.json | 7 - crates/agent/Cargo.toml | 2 - crates/agent/src/agent.rs | 58 +- crates/agent/src/db.rs | 89 ++- crates/agent/src/history_store.rs | 423 ---------- crates/agent/src/native_agent_server.rs | 24 +- crates/agent/src/tests/mod.rs | 6 +- crates/agent/src/thread_store.rs | 289 +++++++ crates/agent_servers/src/codex.rs | 2 +- crates/agent_servers/src/e2e_tests.rs | 60 +- crates/agent_servers/src/gemini.rs | 2 +- crates/agent_ui/src/acp/entry_view_state.rs | 12 +- crates/agent_ui/src/acp/message_editor.rs | 34 +- crates/agent_ui/src/acp/thread_history.rs | 123 ++- crates/agent_ui/src/acp/thread_view.rs | 72 +- crates/agent_ui/src/agent_panel.rs | 346 +++++---- crates/agent_ui/src/agent_ui.rs | 7 +- crates/agent_ui/src/completion_provider.rs | 55 +- crates/agent_ui/src/inline_assistant.rs | 18 +- crates/agent_ui/src/inline_prompt_editor.rs | 18 +- crates/agent_ui/src/mention_set.rs | 32 +- .../agent_ui/src/terminal_inline_assistant.rs | 4 +- crates/agent_ui/src/text_thread_history.rs | 732 ++++++++++++++++++ crates/agent_ui_v2/Cargo.toml | 2 +- crates/agent_ui_v2/src/agent_thread_pane.rs | 37 +- crates/agent_ui_v2/src/agents_panel.rs | 62 +- crates/agent_ui_v2/src/thread_history.rs | 80 +- .../src/text_thread_store.rs | 168 +++- crates/zed/src/main.rs | 17 +- docs/src/ai/agent-panel.md | 4 +- 33 files changed, 1766 insertions(+), 1034 deletions(-) delete mode 100644 crates/agent/src/history_store.rs create mode 100644 crates/agent/src/thread_store.rs create mode 100644 crates/agent_ui/src/text_thread_history.rs diff --git a/Cargo.lock b/Cargo.lock index 0ce59d35561f574c20b00e29cd2b221c78e7fa1f..240e8ae4101801026e4f9e4f0933364d63a0cb53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -159,7 +159,6 @@ dependencies = [ "agent_servers", "agent_settings", "anyhow", - "assistant_text_thread", "chrono", "client", "clock", @@ -429,11 +428,11 @@ name = "agent_ui_v2" version = "0.1.0" dependencies = [ "agent", + "agent-client-protocol", "agent_servers", "agent_settings", "agent_ui", "anyhow", - "assistant_text_thread", "chrono", "db", "editor", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 18595c540b8da1a193146f83c0579655eeae4d48..73ddf48f91213352dc3cc5b79f8b3ef4a7217dca 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -263,12 +263,6 @@ "ctrl-alt-z": "agent::RejectOnce", }, }, - { - "context": "AgentPanel > NavigationMenu", - "bindings": { - "shift-backspace": "agent::DeleteRecentlyOpenThread", - }, - }, { "context": "AgentPanel > Markdown", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index d5cda4f9b78b74a1057d641576f4d1c10e4f5d49..d4249adfb1db4d678af7e7b3ae2960cbab1772c1 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -304,12 +304,6 @@ "cmd-alt-z": "agent::RejectOnce", }, }, - { - "context": "AgentPanel > NavigationMenu", - "bindings": { - "shift-backspace": "agent::DeleteRecentlyOpenThread", - }, - }, { "context": "AgentPanel > Markdown", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index f556fa485c60e421c9602ed7376e1a1da98c23b9..672ad3741358ba3f1ecba54988f28836b7ca083b 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -264,13 +264,6 @@ "shift-alt-z": "agent::RejectOnce", }, }, - { - "context": "AgentPanel > NavigationMenu", - "use_key_equivalents": true, - "bindings": { - "shift-backspace": "agent::DeleteRecentlyOpenThread", - }, - }, { "context": "AgentPanel > Markdown", "use_key_equivalents": true, diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 54d1fc7ef3d1ab23ffdd903466bc4839de36429b..055f93161f984ba679b7f2c75ee1f746ca0c72e8 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -24,7 +24,6 @@ agent-client-protocol.workspace = true agent_servers.workspace = true agent_settings.workspace = true anyhow.workspace = true -assistant_text_thread.workspace = true chrono.workspace = true client.workspace = true cloud_llm_client.workspace = true @@ -76,7 +75,6 @@ zstd.workspace = true [dev-dependencies] agent_servers = { workspace = true, "features" = ["test-support"] } -assistant_text_thread = { workspace = true, "features" = ["test-support"] } client = { workspace = true, "features" = ["test-support"] } clock = { workspace = true, "features" = ["test-support"] } context_server = { workspace = true, "features" = ["test-support"] } diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 5a212352c7f3addf614bec789fd004ef3a9c4613..e3c3ee1cd5beef4358e2dfbeb50146a45ee99063 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -1,6 +1,5 @@ mod db; mod edit_agent; -mod history_store; mod legacy_thread; mod native_agent_server; pub mod outline; @@ -8,15 +7,16 @@ mod templates; #[cfg(test)] mod tests; mod thread; +mod thread_store; mod tool_permissions; mod tools; use context_server::ContextServerId; pub use db::*; -pub use history_store::*; pub use native_agent_server::NativeAgentServer; pub use templates::*; pub use thread::*; +pub use thread_store::*; pub use tool_permissions::*; pub use tools::*; @@ -224,7 +224,7 @@ impl LanguageModels { pub struct NativeAgent { /// Session ID -> Session mapping sessions: HashMap, - history: Entity, + thread_store: Entity, /// Shared project context for all threads project_context: Entity, project_context_needs_refresh: watch::Sender<()>, @@ -243,7 +243,7 @@ pub struct NativeAgent { impl NativeAgent { pub async fn new( project: Entity, - history: Entity, + thread_store: Entity, templates: Arc, prompt_store: Option>, fs: Arc, @@ -283,7 +283,7 @@ impl NativeAgent { watch::channel(()); Self { sessions: HashMap::default(), - history, + thread_store, project_context: cx.new(|_| project_context), project_context_needs_refresh: project_context_needs_refresh_tx, _maintain_project_context: cx.spawn(async move |this, cx| { @@ -808,14 +808,14 @@ impl NativeAgent { let Some(session) = self.sessions.get_mut(&id) else { return; }; - let history = self.history.clone(); + let thread_store = self.thread_store.clone(); session.pending_save = cx.spawn(async move |_, cx| { let Some(database) = database_future.await.map_err(|err| anyhow!(err)).log_err() else { return; }; let db_thread = db_thread.await; database.save_thread(id, db_thread).await.log_err(); - history.update(cx, |history, cx| history.reload(cx)); + thread_store.update(cx, |store, cx| store.reload(cx)); }); } @@ -1504,8 +1504,6 @@ impl TerminalHandle for AcpTerminalHandle { #[cfg(test)] mod internal_tests { - use crate::HistoryEntryId; - use super::*; use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelInfo, MentionUri}; use fs::FakeFs; @@ -1528,12 +1526,10 @@ mod internal_tests { ) .await; let project = Project::test(fs.clone(), [], cx).await; - let text_thread_store = - cx.new(|cx| assistant_text_thread::TextThreadStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = NativeAgent::new( project.clone(), - history_store, + thread_store, Templates::new(), None, fs.clone(), @@ -1590,13 +1586,11 @@ mod internal_tests { let fs = FakeFs::new(cx.executor()); fs.insert_tree("/", json!({ "a": {} })).await; let project = Project::test(fs.clone(), [], cx).await; - let text_thread_store = - cx.new(|cx| assistant_text_thread::TextThreadStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let thread_store = cx.new(|cx| ThreadStore::new(cx)); let connection = NativeAgentConnection( NativeAgent::new( project.clone(), - history_store, + thread_store, Templates::new(), None, fs.clone(), @@ -1668,14 +1662,12 @@ mod internal_tests { .await; let project = Project::test(fs.clone(), [], cx).await; - let text_thread_store = - cx.new(|cx| assistant_text_thread::TextThreadStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let thread_store = cx.new(|cx| ThreadStore::new(cx)); // Create the agent and connection let agent = NativeAgent::new( project.clone(), - history_store, + thread_store, Templates::new(), None, fs.clone(), @@ -1741,12 +1733,10 @@ mod internal_tests { ) .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; - let text_thread_store = - cx.new(|cx| assistant_text_thread::TextThreadStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = NativeAgent::new( project.clone(), - history_store.clone(), + thread_store.clone(), Templates::new(), None, fs.clone(), @@ -1777,7 +1767,7 @@ mod internal_tests { thread.set_summarization_model(Some(summary_model.clone()), cx); }); cx.run_until_parked(); - assert_eq!(history_entries(&history_store, cx), vec![]); + assert_eq!(thread_entries(&thread_store, cx), vec![]); let send = acp_thread.update(cx, |thread, cx| { thread.send( @@ -1840,9 +1830,9 @@ mod internal_tests { // Ensure the thread can be reloaded from disk. assert_eq!( - history_entries(&history_store, cx), + thread_entries(&thread_store, cx), vec![( - HistoryEntryId::AcpThread(session_id.clone()), + session_id.clone(), format!("Explaining {}", path!("/a/b.md")) )] ); @@ -1867,14 +1857,14 @@ mod internal_tests { }); } - fn history_entries( - history: &Entity, + fn thread_entries( + thread_store: &Entity, cx: &mut TestAppContext, - ) -> Vec<(HistoryEntryId, String)> { - history.read_with(cx, |history, _| { - history + ) -> Vec<(acp::SessionId, String)> { + thread_store.read_with(cx, |store, _| { + store .entries() - .map(|e| (e.id(), e.title().to_string())) + .map(|entry| (entry.id.clone(), entry.title.to_string())) .collect::>() }) } diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index d29431e46440d4e256fd22fff1a4c415867a2731..3425997524d1bffabde69b14163555ef9f1342df 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -503,7 +503,10 @@ impl ThreadsDatabase { #[cfg(test)] mod tests { use super::*; - use chrono::TimeZone; + use chrono::{DateTime, TimeZone, Utc}; + use collections::HashMap; + use gpui::TestAppContext; + use std::sync::Arc; #[test] fn test_shared_thread_roundtrip() { @@ -540,4 +543,88 @@ mod tests { "Legacy threads without imported field should default to false" ); } + + fn session_id(value: &str) -> acp::SessionId { + acp::SessionId::new(Arc::::from(value)) + } + + fn make_thread(title: &str, updated_at: DateTime) -> DbThread { + DbThread { + title: title.to_string().into(), + messages: Vec::new(), + updated_at, + detailed_summary: None, + initial_project_snapshot: None, + cumulative_token_usage: Default::default(), + request_token_usage: HashMap::default(), + model: None, + completion_mode: None, + profile: None, + imported: false, + } + } + + #[gpui::test] + async fn test_list_threads_orders_by_updated_at(cx: &mut TestAppContext) { + let database = ThreadsDatabase::new(cx.executor()).unwrap(); + + let older_id = session_id("thread-a"); + let newer_id = session_id("thread-b"); + + let older_thread = make_thread( + "Thread A", + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + ); + let newer_thread = make_thread( + "Thread B", + Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap(), + ); + + database + .save_thread(older_id.clone(), older_thread) + .await + .unwrap(); + database + .save_thread(newer_id.clone(), newer_thread) + .await + .unwrap(); + + let entries = database.list_threads().await.unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].id, newer_id); + assert_eq!(entries[1].id, older_id); + } + + #[gpui::test] + async fn test_save_thread_replaces_metadata(cx: &mut TestAppContext) { + let database = ThreadsDatabase::new(cx.executor()).unwrap(); + + let thread_id = session_id("thread-a"); + let original_thread = make_thread( + "Thread A", + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + ); + let updated_thread = make_thread( + "Thread B", + Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap(), + ); + + database + .save_thread(thread_id.clone(), original_thread) + .await + .unwrap(); + database + .save_thread(thread_id.clone(), updated_thread) + .await + .unwrap(); + + let entries = database.list_threads().await.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].id, thread_id); + assert_eq!(entries[0].title.as_ref(), "Thread B"); + assert_eq!( + entries[0].updated_at, + Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap() + ); + } } diff --git a/crates/agent/src/history_store.rs b/crates/agent/src/history_store.rs deleted file mode 100644 index 1ddeb7bda32d9fe636962b2281c41e83393a4322..0000000000000000000000000000000000000000 --- a/crates/agent/src/history_store.rs +++ /dev/null @@ -1,423 +0,0 @@ -use crate::{DbThread, DbThreadMetadata, ThreadsDatabase}; -use acp_thread::MentionUri; -use agent_client_protocol as acp; -use anyhow::{Context as _, Result, anyhow}; -use assistant_text_thread::{SavedTextThreadMetadata, TextThread}; -use chrono::{DateTime, Utc}; -use db::kvp::KEY_VALUE_STORE; -use gpui::{App, AsyncApp, Entity, SharedString, Task, prelude::*}; -use itertools::Itertools; -use paths::text_threads_dir; -use project::Project; -use serde::{Deserialize, Serialize}; -use std::{collections::VecDeque, path::Path, rc::Rc, sync::Arc, time::Duration}; -use ui::ElementId; -use util::ResultExt as _; - -const MAX_RECENTLY_OPENED_ENTRIES: usize = 6; -const RECENTLY_OPENED_THREADS_KEY: &str = "recent-agent-threads"; -const SAVE_RECENTLY_OPENED_ENTRIES_DEBOUNCE: Duration = Duration::from_millis(50); - -const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread"); - -//todo: We should remove this function once we support loading all acp thread -pub fn load_agent_thread( - session_id: acp::SessionId, - history_store: Entity, - project: Entity, - cx: &mut App, -) -> Task>> { - use agent_servers::{AgentServer, AgentServerDelegate}; - - let server = Rc::new(crate::NativeAgentServer::new( - project.read(cx).fs().clone(), - history_store, - )); - let delegate = AgentServerDelegate::new( - project.read(cx).agent_server_store().clone(), - project.clone(), - None, - None, - ); - let connection = server.connect(None, delegate, cx); - cx.spawn(async move |cx| { - let (agent, _) = connection.await?; - let agent = agent.downcast::().unwrap(); - cx.update(|cx| agent.load_thread(session_id, cx)).await - }) -} - -#[derive(Clone, Debug)] -pub enum HistoryEntry { - AcpThread(DbThreadMetadata), - TextThread(SavedTextThreadMetadata), -} - -impl HistoryEntry { - pub fn updated_at(&self) -> DateTime { - match self { - HistoryEntry::AcpThread(thread) => thread.updated_at, - HistoryEntry::TextThread(text_thread) => text_thread.mtime.to_utc(), - } - } - - pub fn id(&self) -> HistoryEntryId { - match self { - HistoryEntry::AcpThread(thread) => HistoryEntryId::AcpThread(thread.id.clone()), - HistoryEntry::TextThread(text_thread) => { - HistoryEntryId::TextThread(text_thread.path.clone()) - } - } - } - - pub fn mention_uri(&self) -> MentionUri { - match self { - HistoryEntry::AcpThread(thread) => MentionUri::Thread { - id: thread.id.clone(), - name: thread.title.to_string(), - }, - HistoryEntry::TextThread(text_thread) => MentionUri::TextThread { - path: text_thread.path.as_ref().to_owned(), - name: text_thread.title.to_string(), - }, - } - } - - pub fn title(&self) -> &SharedString { - match self { - HistoryEntry::AcpThread(thread) => { - if thread.title.is_empty() { - DEFAULT_TITLE - } else { - &thread.title - } - } - HistoryEntry::TextThread(text_thread) => &text_thread.title, - } - } -} - -/// Generic identifier for a history entry. -#[derive(Clone, PartialEq, Eq, Debug, Hash)] -pub enum HistoryEntryId { - AcpThread(acp::SessionId), - TextThread(Arc), -} - -impl Into for HistoryEntryId { - fn into(self) -> ElementId { - match self { - HistoryEntryId::AcpThread(session_id) => ElementId::Name(session_id.0.into()), - HistoryEntryId::TextThread(path) => ElementId::Path(path), - } - } -} - -#[derive(Serialize, Deserialize, Debug)] -enum SerializedRecentOpen { - AcpThread(String), - TextThread(String), -} - -pub struct HistoryStore { - threads: Vec, - entries: Vec, - text_thread_store: Entity, - recently_opened_entries: VecDeque, - _subscriptions: Vec, - _save_recently_opened_entries_task: Task<()>, -} - -impl HistoryStore { - pub fn new( - text_thread_store: Entity, - cx: &mut Context, - ) -> Self { - let subscriptions = - vec![cx.observe(&text_thread_store, |this, _, cx| this.update_entries(cx))]; - - cx.spawn(async move |this, cx| { - let entries = Self::load_recently_opened_entries(cx).await; - this.update(cx, |this, cx| { - if let Some(entries) = entries.log_err() { - this.recently_opened_entries = entries; - } - - this.reload(cx); - }) - .ok(); - }) - .detach(); - - Self { - text_thread_store, - recently_opened_entries: VecDeque::default(), - threads: Vec::default(), - entries: Vec::default(), - _subscriptions: subscriptions, - _save_recently_opened_entries_task: Task::ready(()), - } - } - - pub fn thread_from_session_id(&self, session_id: &acp::SessionId) -> Option<&DbThreadMetadata> { - self.threads.iter().find(|thread| &thread.id == session_id) - } - - pub fn load_thread( - &mut self, - id: acp::SessionId, - cx: &mut Context, - ) -> Task>> { - let database_future = ThreadsDatabase::connect(cx); - cx.background_spawn(async move { - let database = database_future.await.map_err(|err| anyhow!(err))?; - database.load_thread(id).await - }) - } - - pub fn save_thread( - &mut self, - id: acp::SessionId, - thread: crate::DbThread, - cx: &mut Context, - ) -> Task> { - let database_future = ThreadsDatabase::connect(cx); - cx.spawn(async move |this, cx| { - let database = database_future.await.map_err(|err| anyhow!(err))?; - database.save_thread(id, thread).await?; - this.update(cx, |this, cx| this.reload(cx)) - }) - } - - pub fn delete_thread( - &mut self, - id: acp::SessionId, - cx: &mut Context, - ) -> Task> { - let database_future = ThreadsDatabase::connect(cx); - cx.spawn(async move |this, cx| { - let database = database_future.await.map_err(|err| anyhow!(err))?; - database.delete_thread(id.clone()).await?; - this.update(cx, |this, cx| this.reload(cx)) - }) - } - - pub fn delete_threads(&mut self, cx: &mut Context) -> Task> { - let database_future = ThreadsDatabase::connect(cx); - cx.spawn(async move |this, cx| { - let database = database_future.await.map_err(|err| anyhow!(err))?; - database.delete_threads().await?; - this.update(cx, |this, cx| this.reload(cx)) - }) - } - - pub fn delete_text_thread( - &mut self, - path: Arc, - cx: &mut Context, - ) -> Task> { - self.text_thread_store - .update(cx, |store, cx| store.delete_local(path, cx)) - } - - pub fn load_text_thread( - &self, - path: Arc, - cx: &mut Context, - ) -> Task>> { - self.text_thread_store - .update(cx, |store, cx| store.open_local(path, cx)) - } - - pub fn reload(&self, cx: &mut Context) { - let database_connection = ThreadsDatabase::connect(cx); - cx.spawn(async move |this, cx| { - let database = database_connection.await; - let threads = database.map_err(|err| anyhow!(err))?.list_threads().await?; - this.update(cx, |this, cx| { - if this.recently_opened_entries.len() < MAX_RECENTLY_OPENED_ENTRIES { - for thread in threads - .iter() - .take(MAX_RECENTLY_OPENED_ENTRIES - this.recently_opened_entries.len()) - .rev() - { - this.push_recently_opened_entry( - HistoryEntryId::AcpThread(thread.id.clone()), - cx, - ) - } - } - this.threads = threads; - this.update_entries(cx); - }) - }) - .detach_and_log_err(cx); - } - - fn update_entries(&mut self, cx: &mut Context) { - #[cfg(debug_assertions)] - if std::env::var("ZED_SIMULATE_NO_THREAD_HISTORY").is_ok() { - return; - } - let mut history_entries = Vec::new(); - history_entries.extend(self.threads.iter().cloned().map(HistoryEntry::AcpThread)); - history_entries.extend( - self.text_thread_store - .read(cx) - .unordered_text_threads() - .cloned() - .map(HistoryEntry::TextThread), - ); - - history_entries.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.updated_at())); - self.entries = history_entries; - cx.notify() - } - - pub fn is_empty(&self, _cx: &App) -> bool { - self.entries.is_empty() - } - - pub fn recently_opened_entries(&self, cx: &App) -> Vec { - #[cfg(debug_assertions)] - if std::env::var("ZED_SIMULATE_NO_THREAD_HISTORY").is_ok() { - return Vec::new(); - } - - let thread_entries = self.threads.iter().flat_map(|thread| { - self.recently_opened_entries - .iter() - .enumerate() - .flat_map(|(index, entry)| match entry { - HistoryEntryId::AcpThread(id) if &thread.id == id => { - Some((index, HistoryEntry::AcpThread(thread.clone()))) - } - _ => None, - }) - }); - - let context_entries = self - .text_thread_store - .read(cx) - .unordered_text_threads() - .flat_map(|text_thread| { - self.recently_opened_entries - .iter() - .enumerate() - .flat_map(|(index, entry)| match entry { - HistoryEntryId::TextThread(path) if &text_thread.path == path => { - Some((index, HistoryEntry::TextThread(text_thread.clone()))) - } - _ => None, - }) - }); - - thread_entries - .chain(context_entries) - // optimization to halt iteration early - .take(self.recently_opened_entries.len()) - .sorted_unstable_by_key(|(index, _)| *index) - .map(|(_, entry)| entry) - .collect() - } - - fn save_recently_opened_entries(&mut self, cx: &mut Context) { - let serialized_entries = self - .recently_opened_entries - .iter() - .filter_map(|entry| match entry { - HistoryEntryId::TextThread(path) => path.file_name().map(|file| { - SerializedRecentOpen::TextThread(file.to_string_lossy().into_owned()) - }), - HistoryEntryId::AcpThread(id) => { - Some(SerializedRecentOpen::AcpThread(id.to_string())) - } - }) - .collect::>(); - - self._save_recently_opened_entries_task = cx.spawn(async move |_, cx| { - let content = serde_json::to_string(&serialized_entries).unwrap(); - cx.background_executor() - .timer(SAVE_RECENTLY_OPENED_ENTRIES_DEBOUNCE) - .await; - - if cfg!(any(feature = "test-support", test)) { - return; - } - KEY_VALUE_STORE - .write_kvp(RECENTLY_OPENED_THREADS_KEY.to_owned(), content) - .await - .log_err(); - }); - } - - fn load_recently_opened_entries(cx: &AsyncApp) -> Task>> { - cx.background_spawn(async move { - if cfg!(any(feature = "test-support", test)) { - log::warn!("history store does not persist in tests"); - return Ok(VecDeque::new()); - } - let json = KEY_VALUE_STORE - .read_kvp(RECENTLY_OPENED_THREADS_KEY)? - .unwrap_or("[]".to_string()); - let entries = serde_json::from_str::>(&json) - .context("deserializing persisted agent panel navigation history")? - .into_iter() - .take(MAX_RECENTLY_OPENED_ENTRIES) - .flat_map(|entry| match entry { - SerializedRecentOpen::AcpThread(id) => { - Some(HistoryEntryId::AcpThread(acp::SessionId::new(id.as_str()))) - } - SerializedRecentOpen::TextThread(file_name) => Some( - HistoryEntryId::TextThread(text_threads_dir().join(file_name).into()), - ), - }) - .collect(); - Ok(entries) - }) - } - - pub fn push_recently_opened_entry(&mut self, entry: HistoryEntryId, cx: &mut Context) { - self.recently_opened_entries - .retain(|old_entry| old_entry != &entry); - self.recently_opened_entries.push_front(entry); - self.recently_opened_entries - .truncate(MAX_RECENTLY_OPENED_ENTRIES); - self.save_recently_opened_entries(cx); - } - - pub fn remove_recently_opened_thread(&mut self, id: acp::SessionId, cx: &mut Context) { - self.recently_opened_entries.retain( - |entry| !matches!(entry, HistoryEntryId::AcpThread(thread_id) if thread_id == &id), - ); - self.save_recently_opened_entries(cx); - } - - pub fn replace_recently_opened_text_thread( - &mut self, - old_path: &Path, - new_path: &Arc, - cx: &mut Context, - ) { - for entry in &mut self.recently_opened_entries { - match entry { - HistoryEntryId::TextThread(path) if path.as_ref() == old_path => { - *entry = HistoryEntryId::TextThread(new_path.clone()); - break; - } - _ => {} - } - } - self.save_recently_opened_entries(cx); - } - - pub fn remove_recently_opened_entry(&mut self, entry: &HistoryEntryId, cx: &mut Context) { - self.recently_opened_entries - .retain(|old_entry| old_entry != entry); - self.save_recently_opened_entries(cx); - } - - pub fn entries(&self) -> impl Iterator { - self.entries.iter().cloned() - } -} diff --git a/crates/agent/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs index 95312fd32536b99059e2ebc6ebd0a9ea522f94be..f87b2534c1d7d83c37c2e3f361110caed4783f0b 100644 --- a/crates/agent/src/native_agent_server.rs +++ b/crates/agent/src/native_agent_server.rs @@ -10,17 +10,17 @@ use gpui::{App, Entity, SharedString, Task}; use prompt_store::PromptStore; use settings::{LanguageModelSelection, Settings as _, update_settings_file}; -use crate::{HistoryStore, NativeAgent, NativeAgentConnection, templates::Templates}; +use crate::{NativeAgent, NativeAgentConnection, ThreadStore, templates::Templates}; #[derive(Clone)] pub struct NativeAgentServer { fs: Arc, - history: Entity, + thread_store: Entity, } impl NativeAgentServer { - pub fn new(fs: Arc, history: Entity) -> Self { - Self { fs, history } + pub fn new(fs: Arc, thread_store: Entity) -> Self { + Self { fs, thread_store } } } @@ -50,7 +50,7 @@ impl AgentServer for NativeAgentServer { ); let project = delegate.project().clone(); let fs = self.fs.clone(); - let history = self.history.clone(); + let thread_store = self.thread_store.clone(); let prompt_store = PromptStore::global(cx); cx.spawn(async move |cx| { log::debug!("Creating templates for native agent"); @@ -59,7 +59,8 @@ impl AgentServer for NativeAgentServer { log::debug!("Creating native agent entity"); let agent = - NativeAgent::new(project, history, templates, Some(prompt_store), fs, cx).await?; + NativeAgent::new(project, thread_store, templates, Some(prompt_store), fs, cx) + .await?; // Create the connection wrapper let connection = NativeAgentConnection(agent); @@ -113,11 +114,10 @@ fn model_id_to_selection(model_id: &acp::ModelId) -> LanguageModelSelection { mod tests { use super::*; - use assistant_text_thread::TextThreadStore; use gpui::AppContext; agent_servers::e2e_tests::common_e2e_tests!( - async |fs, project, cx| { + async |fs, cx| { let auth = cx.update(|cx| { prompt_store::init(cx); let registry = language_model::LanguageModelRegistry::read_global(cx); @@ -145,13 +145,9 @@ mod tests { }); }); - let history = cx.update(|cx| { - let text_thread_store = - cx.new(move |cx| TextThreadStore::fake(project.clone(), cx)); - cx.new(move |cx| HistoryStore::new(text_thread_store, cx)) - }); + let thread_store = cx.update(|cx| cx.new(|cx| ThreadStore::new(cx))); - NativeAgentServer::new(fs.clone(), history) + NativeAgentServer::new(fs.clone(), thread_store) }, allow_option_id = "allow" ); diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 6a81d3daa0b4f37affc1cebb853010f6940718bf..e65644deddceb12d1c954d20a0658fc9f4c264e5 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -2741,14 +2741,12 @@ async fn test_agent_connection(cx: &mut TestAppContext) { fake_fs.insert_tree(path!("/test"), json!({})).await; let project = Project::test(fake_fs.clone(), [Path::new("/test")], cx).await; let cwd = Path::new("/test"); - let text_thread_store = - cx.new(|cx| assistant_text_thread::TextThreadStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let thread_store = cx.new(|cx| ThreadStore::new(cx)); // Create agent and connection let agent = NativeAgent::new( project.clone(), - history_store, + thread_store, templates.clone(), None, fake_fs.clone(), diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..bba5cf2c2b9eec8022838eb83eb9ddd471ae40ea --- /dev/null +++ b/crates/agent/src/thread_store.rs @@ -0,0 +1,289 @@ +use crate::{DbThread, DbThreadMetadata, ThreadsDatabase}; +use agent_client_protocol as acp; +use anyhow::{Result, anyhow}; +use gpui::{App, Context, Entity, Task, prelude::*}; +use project::Project; +use std::rc::Rc; + +// TODO: Remove once ACP thread loading is fully handled elsewhere. +pub fn load_agent_thread( + session_id: acp::SessionId, + thread_store: Entity, + project: Entity, + cx: &mut App, +) -> Task>> { + use agent_servers::{AgentServer, AgentServerDelegate}; + + let server = Rc::new(crate::NativeAgentServer::new( + project.read(cx).fs().clone(), + thread_store, + )); + let delegate = AgentServerDelegate::new( + project.read(cx).agent_server_store().clone(), + project.clone(), + None, + None, + ); + let connection = server.connect(None, delegate, cx); + cx.spawn(async move |cx| { + let (agent, _) = connection.await?; + let agent = agent.downcast::().unwrap(); + cx.update(|cx| agent.load_thread(session_id, cx)).await + }) +} + +pub struct ThreadStore { + threads: Vec, +} + +impl ThreadStore { + pub fn new(cx: &mut Context) -> Self { + let this = Self { + threads: Vec::new(), + }; + this.reload(cx); + this + } + + pub fn thread_from_session_id(&self, session_id: &acp::SessionId) -> Option<&DbThreadMetadata> { + self.threads.iter().find(|thread| &thread.id == session_id) + } + + pub fn load_thread( + &mut self, + id: acp::SessionId, + cx: &mut Context, + ) -> Task>> { + let database_future = ThreadsDatabase::connect(cx); + cx.background_spawn(async move { + let database = database_future.await.map_err(|err| anyhow!(err))?; + database.load_thread(id).await + }) + } + + pub fn save_thread( + &mut self, + id: acp::SessionId, + thread: crate::DbThread, + cx: &mut Context, + ) -> Task> { + let database_future = ThreadsDatabase::connect(cx); + cx.spawn(async move |this, cx| { + let database = database_future.await.map_err(|err| anyhow!(err))?; + database.save_thread(id, thread).await?; + this.update(cx, |this, cx| this.reload(cx)) + }) + } + + pub fn delete_thread( + &mut self, + id: acp::SessionId, + cx: &mut Context, + ) -> Task> { + let database_future = ThreadsDatabase::connect(cx); + cx.spawn(async move |this, cx| { + let database = database_future.await.map_err(|err| anyhow!(err))?; + database.delete_thread(id.clone()).await?; + this.update(cx, |this, cx| this.reload(cx)) + }) + } + + pub fn delete_threads(&mut self, cx: &mut Context) -> Task> { + let database_future = ThreadsDatabase::connect(cx); + cx.spawn(async move |this, cx| { + let database = database_future.await.map_err(|err| anyhow!(err))?; + database.delete_threads().await?; + this.update(cx, |this, cx| this.reload(cx)) + }) + } + + pub fn reload(&self, cx: &mut Context) { + let database_connection = ThreadsDatabase::connect(cx); + cx.spawn(async move |this, cx| { + let database = database_connection.await.map_err(|err| anyhow!(err))?; + let threads = database.list_threads().await?; + this.update(cx, |this, cx| { + this.threads = threads; + cx.notify(); + }) + }) + .detach_and_log_err(cx); + } + + pub fn is_empty(&self) -> bool { + self.threads.is_empty() + } + + pub fn entries(&self) -> impl Iterator + '_ { + self.threads.iter().cloned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{DateTime, TimeZone, Utc}; + use collections::HashMap; + use gpui::TestAppContext; + use std::sync::Arc; + + fn session_id(value: &str) -> acp::SessionId { + acp::SessionId::new(Arc::::from(value)) + } + + fn make_thread(title: &str, updated_at: DateTime) -> DbThread { + DbThread { + title: title.to_string().into(), + messages: Vec::new(), + updated_at, + detailed_summary: None, + initial_project_snapshot: None, + cumulative_token_usage: Default::default(), + request_token_usage: HashMap::default(), + model: None, + completion_mode: None, + profile: None, + imported: false, + } + } + + #[gpui::test] + async fn test_entries_are_sorted_by_updated_at(cx: &mut TestAppContext) { + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + cx.run_until_parked(); + + let older_id = session_id("thread-a"); + let newer_id = session_id("thread-b"); + + let older_thread = make_thread( + "Thread A", + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + ); + let newer_thread = make_thread( + "Thread B", + Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap(), + ); + + let save_older = thread_store.update(cx, |store, cx| { + store.save_thread(older_id.clone(), older_thread, cx) + }); + save_older.await.unwrap(); + + let save_newer = thread_store.update(cx, |store, cx| { + store.save_thread(newer_id.clone(), newer_thread, cx) + }); + save_newer.await.unwrap(); + + cx.run_until_parked(); + + let entries: Vec<_> = thread_store.read_with(cx, |store, _cx| store.entries().collect()); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].id, newer_id); + assert_eq!(entries[1].id, older_id); + } + + #[gpui::test] + async fn test_delete_threads_clears_entries(cx: &mut TestAppContext) { + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + cx.run_until_parked(); + + let thread_id = session_id("thread-a"); + let thread = make_thread( + "Thread A", + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + ); + + let save_task = + thread_store.update(cx, |store, cx| store.save_thread(thread_id, thread, cx)); + save_task.await.unwrap(); + + cx.run_until_parked(); + assert!(!thread_store.read_with(cx, |store, _cx| store.is_empty())); + + let delete_task = thread_store.update(cx, |store, cx| store.delete_threads(cx)); + delete_task.await.unwrap(); + cx.run_until_parked(); + + assert!(thread_store.read_with(cx, |store, _cx| store.is_empty())); + } + + #[gpui::test] + async fn test_delete_thread_removes_only_target(cx: &mut TestAppContext) { + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + cx.run_until_parked(); + + let first_id = session_id("thread-a"); + let second_id = session_id("thread-b"); + + let first_thread = make_thread( + "Thread A", + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + ); + let second_thread = make_thread( + "Thread B", + Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap(), + ); + + let save_first = thread_store.update(cx, |store, cx| { + store.save_thread(first_id.clone(), first_thread, cx) + }); + save_first.await.unwrap(); + let save_second = thread_store.update(cx, |store, cx| { + store.save_thread(second_id.clone(), second_thread, cx) + }); + save_second.await.unwrap(); + cx.run_until_parked(); + + let delete_task = + thread_store.update(cx, |store, cx| store.delete_thread(first_id.clone(), cx)); + delete_task.await.unwrap(); + cx.run_until_parked(); + + let entries: Vec<_> = thread_store.read_with(cx, |store, _cx| store.entries().collect()); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].id, second_id); + } + + #[gpui::test] + async fn test_save_thread_refreshes_ordering(cx: &mut TestAppContext) { + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + cx.run_until_parked(); + + let first_id = session_id("thread-a"); + let second_id = session_id("thread-b"); + + let first_thread = make_thread( + "Thread A", + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + ); + let second_thread = make_thread( + "Thread B", + Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap(), + ); + + let save_first = thread_store.update(cx, |store, cx| { + store.save_thread(first_id.clone(), first_thread, cx) + }); + save_first.await.unwrap(); + let save_second = thread_store.update(cx, |store, cx| { + store.save_thread(second_id.clone(), second_thread, cx) + }); + save_second.await.unwrap(); + cx.run_until_parked(); + + let updated_first = make_thread( + "Thread A", + Utc.with_ymd_and_hms(2024, 1, 3, 0, 0, 0).unwrap(), + ); + let update_task = thread_store.update(cx, |store, cx| { + store.save_thread(first_id.clone(), updated_first, cx) + }); + update_task.await.unwrap(); + cx.run_until_parked(); + + let entries: Vec<_> = thread_store.read_with(cx, |store, _cx| store.entries().collect()); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].id, first_id); + assert_eq!(entries[1].id, second_id); + } +} diff --git a/crates/agent_servers/src/codex.rs b/crates/agent_servers/src/codex.rs index 5585cbc2cb22c5be0695a9d7b83b92a0c0a09383..1341c65fb0f1250849cf2b1ef42533da1feb3e79 100644 --- a/crates/agent_servers/src/codex.rs +++ b/crates/agent_servers/src/codex.rs @@ -20,7 +20,7 @@ pub struct Codex; pub(crate) mod tests { use super::*; - crate::common_e2e_tests!(async |_, _, _| Codex, allow_option_id = "proceed_once"); + crate::common_e2e_tests!(async |_, _| Codex, allow_option_id = "proceed_once"); } impl AgentServer for Codex { diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 99d6c3a6b85c1c292d96abd6770b25042d06ef71..19004f085ba575589171d6ee3a10eb37aacffec0 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -19,17 +19,11 @@ use util::path; pub async fn test_basic(server: F, cx: &mut TestAppContext) where T: AgentServer + 'static, - F: AsyncFn(&Arc, &Entity, &mut TestAppContext) -> T, + F: AsyncFn(&Arc, &mut TestAppContext) -> T, { let fs = init_test(cx).await as Arc; let project = Project::test(fs.clone(), [], cx).await; - let thread = new_test_thread( - server(&fs, &project, cx).await, - project.clone(), - "/private/tmp", - cx, - ) - .await; + let thread = new_test_thread(server(&fs, cx).await, project.clone(), "/private/tmp", cx).await; thread .update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx)) @@ -56,7 +50,7 @@ where pub async fn test_path_mentions(server: F, cx: &mut TestAppContext) where T: AgentServer + 'static, - F: AsyncFn(&Arc, &Entity, &mut TestAppContext) -> T, + F: AsyncFn(&Arc, &mut TestAppContext) -> T, { let fs = init_test(cx).await as _; @@ -71,13 +65,7 @@ where ) .expect("failed to write file"); let project = Project::example([tempdir.path()], &mut cx.to_async()).await; - let thread = new_test_thread( - server(&fs, &project, cx).await, - project.clone(), - tempdir.path(), - cx, - ) - .await; + let thread = new_test_thread(server(&fs, cx).await, project.clone(), tempdir.path(), cx).await; thread .update(cx, |thread, cx| { thread.send( @@ -120,7 +108,7 @@ where pub async fn test_tool_call(server: F, cx: &mut TestAppContext) where T: AgentServer + 'static, - F: AsyncFn(&Arc, &Entity, &mut TestAppContext) -> T, + F: AsyncFn(&Arc, &mut TestAppContext) -> T, { let fs = init_test(cx).await as _; @@ -129,13 +117,7 @@ where std::fs::write(&foo_path, "Lorem ipsum dolor").expect("failed to write file"); let project = Project::example([tempdir.path()], &mut cx.to_async()).await; - let thread = new_test_thread( - server(&fs, &project, cx).await, - project.clone(), - "/private/tmp", - cx, - ) - .await; + let thread = new_test_thread(server(&fs, cx).await, project.clone(), "/private/tmp", cx).await; thread .update(cx, |thread, cx| { @@ -175,17 +157,11 @@ pub async fn test_tool_call_with_permission( cx: &mut TestAppContext, ) where T: AgentServer + 'static, - F: AsyncFn(&Arc, &Entity, &mut TestAppContext) -> T, + F: AsyncFn(&Arc, &mut TestAppContext) -> T, { let fs = init_test(cx).await as Arc; let project = Project::test(fs.clone(), [path!("/private/tmp").as_ref()], cx).await; - let thread = new_test_thread( - server(&fs, &project, cx).await, - project.clone(), - "/private/tmp", - cx, - ) - .await; + let thread = new_test_thread(server(&fs, cx).await, project.clone(), "/private/tmp", cx).await; let full_turn = thread.update(cx, |thread, cx| { thread.send_raw( r#"Run exactly `touch hello.txt && echo "Hello, world!" | tee hello.txt` in the terminal."#, @@ -276,18 +252,12 @@ pub async fn test_tool_call_with_permission( pub async fn test_cancel(server: F, cx: &mut TestAppContext) where T: AgentServer + 'static, - F: AsyncFn(&Arc, &Entity, &mut TestAppContext) -> T, + F: AsyncFn(&Arc, &mut TestAppContext) -> T, { let fs = init_test(cx).await as Arc; let project = Project::test(fs.clone(), [path!("/private/tmp").as_ref()], cx).await; - let thread = new_test_thread( - server(&fs, &project, cx).await, - project.clone(), - "/private/tmp", - cx, - ) - .await; + let thread = new_test_thread(server(&fs, cx).await, project.clone(), "/private/tmp", cx).await; let _ = thread.update(cx, |thread, cx| { thread.send_raw( r#"Run exactly `touch hello.txt && echo "Hello, world!" | tee hello.txt` in the terminal."#, @@ -355,17 +325,11 @@ where pub async fn test_thread_drop(server: F, cx: &mut TestAppContext) where T: AgentServer + 'static, - F: AsyncFn(&Arc, &Entity, &mut TestAppContext) -> T, + F: AsyncFn(&Arc, &mut TestAppContext) -> T, { let fs = init_test(cx).await as Arc; let project = Project::test(fs.clone(), [], cx).await; - let thread = new_test_thread( - server(&fs, &project, cx).await, - project.clone(), - "/private/tmp", - cx, - ) - .await; + let thread = new_test_thread(server(&fs, cx).await, project.clone(), "/private/tmp", cx).await; thread .update(cx, |thread, cx| thread.send_raw("Hello from test!", cx)) diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index bdd3a5a384e3002552e3cd8e2566c75c342e244a..8e0573e2735ebb692ec4e9417bc71e410970a590 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -95,7 +95,7 @@ pub(crate) mod tests { use super::*; use std::path::Path; - crate::common_e2e_tests!(async |_, _, _| Gemini, allow_option_id = "proceed_once"); + crate::common_e2e_tests!(async |_, _| Gemini, allow_option_id = "proceed_once"); pub fn local_command() -> AgentServerCommand { let cli_path = Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index feae74a86bc241c5d2e01f0941eafc60210f1bf6..22a4eb4f85e3781fbdde034bcb39655d017e7578 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -1,7 +1,7 @@ use std::{cell::RefCell, ops::Range, rc::Rc}; use acp_thread::{AcpThread, AgentThreadEntry}; -use agent::HistoryStore; +use agent::ThreadStore; use agent_client_protocol::{self as acp, ToolCallId}; use collections::HashMap; use editor::{Editor, EditorMode, MinimapVisibility, SizingBehavior}; @@ -23,7 +23,7 @@ use crate::acp::message_editor::{MessageEditor, MessageEditorEvent}; pub struct EntryViewState { workspace: WeakEntity, project: WeakEntity, - history_store: Entity, + history_store: Entity, prompt_store: Option>, entries: Vec, prompt_capabilities: Rc>, @@ -35,7 +35,7 @@ impl EntryViewState { pub fn new( workspace: WeakEntity, project: WeakEntity, - history_store: Entity, + history_store: Entity, prompt_store: Option>, prompt_capabilities: Rc>, available_commands: Rc>>, @@ -399,9 +399,8 @@ mod tests { use std::{path::Path, rc::Rc}; use acp_thread::{AgentConnection, StubAgentConnection}; - use agent::HistoryStore; + use agent::ThreadStore; use agent_client_protocol as acp; - use assistant_text_thread::TextThreadStore; use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; use editor::RowInfo; use fs::FakeFs; @@ -452,8 +451,7 @@ mod tests { connection.send_update(session_id, acp::SessionUpdate::ToolCall(tool_call), cx) }); - let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let history_store = cx.new(|cx| ThreadStore::new(cx)); let view_state = cx.new(|_cx| { EntryViewState::new( diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 9be8ca32e0fb186f8129ecb0364cbb3d84434bad..acbf05a77c9a1b2af711b253050fe9bc40d458b3 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -10,7 +10,7 @@ use crate::{ }, }; use acp_thread::MentionUri; -use agent::HistoryStore; +use agent::ThreadStore; use agent_client_protocol as acp; use anyhow::{Result, anyhow}; use collections::HashSet; @@ -100,7 +100,7 @@ impl MessageEditor { pub fn new( workspace: WeakEntity, project: WeakEntity, - history_store: Entity, + history_store: Entity, prompt_store: Option>, prompt_capabilities: Rc>, available_commands: Rc>>, @@ -1062,9 +1062,8 @@ mod tests { use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc}; use acp_thread::MentionUri; - use agent::{HistoryStore, outline}; + use agent::{ThreadStore, outline}; use agent_client_protocol as acp; - use assistant_text_thread::TextThreadStore; use editor::{AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset}; use fs::FakeFs; use futures::StreamExt as _; @@ -1096,8 +1095,7 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let history_store = cx.new(|cx| ThreadStore::new(cx)); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -1201,8 +1199,7 @@ mod tests { .await; let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; - let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let history_store = cx.new(|cx| ThreadStore::new(cx)); 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![])); @@ -1358,8 +1355,7 @@ mod tests { let mut cx = VisualTestContext::from_window(*window, cx); - let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let history_store = cx.new(|cx| ThreadStore::new(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"), @@ -1588,8 +1584,7 @@ mod tests { opened_editors.push(buffer); } - let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let history_store = cx.new(|cx| ThreadStore::new(cx)); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { @@ -2081,8 +2076,7 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let history_store = cx.new(|cx| ThreadStore::new(cx)); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -2179,8 +2173,7 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let history_store = cx.new(|cx| ThreadStore::new(cx)); // Create a thread metadata to insert as summary let thread_metadata = agent::DbThreadMetadata { @@ -2255,8 +2248,7 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let history_store = cx.new(|cx| ThreadStore::new(cx)); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -2317,8 +2309,7 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let history_store = cx.new(|cx| ThreadStore::new(cx)); let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); @@ -2472,8 +2463,7 @@ mod tests { }); }); - let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let history_store = cx.new(|cx| ThreadStore::new(cx)); // Create a new `MessageEditor`. The `EditorMode::full()` has to be used // to ensure we have a fixed viewport, so we can eventually actually diff --git a/crates/agent_ui/src/acp/thread_history.rs b/crates/agent_ui/src/acp/thread_history.rs index a885e52a05e342dbcd81d28a970560b3047ef9c0..4a380bb4e79583ec9e3883bbb5a0f36e10c63790 100644 --- a/crates/agent_ui/src/acp/thread_history.rs +++ b/crates/agent_ui/src/acp/thread_history.rs @@ -1,6 +1,6 @@ use crate::acp::AcpThreadView; use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread}; -use agent::{HistoryEntry, HistoryStore}; +use agent::{DbThreadMetadata, ThreadStore}; use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; use editor::{Editor, EditorEvent}; use fuzzy::StringMatchCandidate; @@ -12,12 +12,22 @@ use std::{fmt::Display, ops::Range}; use text::Bias; use time::{OffsetDateTime, UtcOffset}; use ui::{ - HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, WithScrollbar, - prelude::*, + ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, + WithScrollbar, prelude::*, }; +const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread"); + +fn thread_title(entry: &DbThreadMetadata) -> &SharedString { + if entry.title.is_empty() { + DEFAULT_TITLE + } else { + &entry.title + } +} + pub struct AcpThreadHistory { - pub(crate) history_store: Entity, + pub(crate) thread_store: Entity, scroll_handle: UniformListScrollHandle, selected_index: usize, hovered_index: Option, @@ -33,17 +43,17 @@ pub struct AcpThreadHistory { enum ListItemType { BucketSeparator(TimeBucket), Entry { - entry: HistoryEntry, + entry: DbThreadMetadata, format: EntryTimeFormat, }, SearchResult { - entry: HistoryEntry, + entry: DbThreadMetadata, positions: Vec, }, } impl ListItemType { - fn history_entry(&self) -> Option<&HistoryEntry> { + fn history_entry(&self) -> Option<&DbThreadMetadata> { match self { ListItemType::Entry { entry, .. } => Some(entry), ListItemType::SearchResult { entry, .. } => Some(entry), @@ -53,14 +63,14 @@ impl ListItemType { } pub enum ThreadHistoryEvent { - Open(HistoryEntry), + Open(DbThreadMetadata), } impl EventEmitter for AcpThreadHistory {} impl AcpThreadHistory { pub(crate) fn new( - history_store: Entity, + thread_store: Entity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -81,14 +91,14 @@ impl AcpThreadHistory { } }); - let history_store_subscription = cx.observe(&history_store, |this, _, cx| { + let thread_store_subscription = cx.observe(&thread_store, |this, _, cx| { this.update_visible_items(true, cx); }); let scroll_handle = UniformListScrollHandle::default(); let mut this = Self { - history_store, + thread_store, scroll_handle, selected_index: 0, hovered_index: None, @@ -100,7 +110,7 @@ impl AcpThreadHistory { .unwrap(), search_query: SharedString::default(), confirming_delete_history: false, - _subscriptions: vec![search_editor_subscription, history_store_subscription], + _subscriptions: vec![search_editor_subscription, thread_store_subscription], _update_task: Task::ready(()), }; this.update_visible_items(false, cx); @@ -109,7 +119,7 @@ impl AcpThreadHistory { fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { let entries = self - .history_store + .thread_store .update(cx, |store, _| store.entries().collect()); let new_list_items = if self.search_query.is_empty() { self.add_list_separators(entries, cx) @@ -126,13 +136,12 @@ impl AcpThreadHistory { 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 { - let history_entry_id = history_entry.id(); new_visible_items .iter() .position(|visible_entry| { visible_entry .history_entry() - .is_some_and(|entry| entry.id() == history_entry_id) + .is_some_and(|entry| entry.id == history_entry.id) }) .unwrap_or(0) } else { @@ -147,18 +156,18 @@ impl AcpThreadHistory { }); } - fn add_list_separators(&self, entries: Vec, cx: &App) -> Task> { + 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_date = entry - .updated_at() - .with_timezone(&Local) - .naive_local() - .date(); + let entry_date = entry.updated_at.with_timezone(&Local).naive_local().date(); let entry_bucket = TimeBucket::from_dates(today, entry_date); if Some(entry_bucket) != bucket { @@ -177,7 +186,7 @@ impl AcpThreadHistory { fn filter_search_results( &self, - entries: Vec, + entries: Vec, cx: &App, ) -> Task> { let query = self.search_query.clone(); @@ -187,7 +196,7 @@ impl AcpThreadHistory { let mut candidates = Vec::with_capacity(entries.len()); for (idx, entry) in entries.iter().enumerate() { - candidates.push(StringMatchCandidate::new(idx, entry.title())); + candidates.push(StringMatchCandidate::new(idx, thread_title(entry))); } const MAX_MATCHES: usize = 100; @@ -218,11 +227,11 @@ impl AcpThreadHistory { self.visible_items.is_empty() && !self.search_query.is_empty() } - fn selected_history_entry(&self) -> Option<&HistoryEntry> { + fn selected_history_entry(&self) -> Option<&DbThreadMetadata> { self.get_history_entry(self.selected_index) } - fn get_history_entry(&self, visible_items_ix: usize) -> Option<&HistoryEntry> { + fn get_history_entry(&self, visible_items_ix: usize) -> Option<&DbThreadMetadata> { self.visible_items.get(visible_items_ix)?.history_entry() } @@ -322,19 +331,14 @@ impl AcpThreadHistory { return; }; - let task = match entry { - HistoryEntry::AcpThread(thread) => self - .history_store - .update(cx, |this, cx| this.delete_thread(thread.id.clone(), cx)), - HistoryEntry::TextThread(text_thread) => self.history_store.update(cx, |this, cx| { - this.delete_text_thread(text_thread.path.clone(), cx) - }), - }; + let task = self + .thread_store + .update(cx, |store, cx| store.delete_thread(entry.id.clone(), cx)); task.detach_and_log_err(cx); } fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { - self.history_store.update(cx, |store, cx| { + self.thread_store.update(cx, |store, cx| { store.delete_threads(cx).detach_and_log_err(cx) }); self.confirming_delete_history = false; @@ -393,7 +397,7 @@ impl AcpThreadHistory { fn render_history_entry( &self, - entry: &HistoryEntry, + entry: &DbThreadMetadata, format: EntryTimeFormat, ix: usize, highlight_positions: Vec, @@ -401,11 +405,11 @@ impl AcpThreadHistory { ) -> AnyElement { let selected = ix == self.selected_index; let hovered = Some(ix) == self.hovered_index; - let timestamp = entry.updated_at().timestamp(); + let timestamp = entry.updated_at.timestamp(); let display_text = match format { EntryTimeFormat::DateAndTime => { - let entry_time = entry.updated_at(); + let entry_time = entry.updated_at; let now = Utc::now(); let duration = now.signed_duration_since(entry_time); let days = duration.num_days(); @@ -415,7 +419,7 @@ impl AcpThreadHistory { EntryTimeFormat::TimeOnly => format.format_timestamp(timestamp, self.local_timezone), }; - let title = entry.title().clone(); + let title = thread_title(entry).clone(); let full_date = EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone); @@ -433,7 +437,7 @@ impl AcpThreadHistory { .gap_2() .justify_between() .child( - HighlightedLabel::new(entry.title(), highlight_positions) + HighlightedLabel::new(thread_title(entry), highlight_positions) .size(LabelSize::Small) .truncate(), ) @@ -486,7 +490,7 @@ impl Focusable for AcpThreadHistory { impl Render for AcpThreadHistory { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let has_no_history = self.history_store.read(cx).is_empty(cx); + let has_no_history = self.thread_store.read(cx).is_empty(); v_flex() .key_context("ThreadHistory") @@ -619,7 +623,7 @@ impl Render for AcpThreadHistory { #[derive(IntoElement)] pub struct AcpHistoryEntryElement { - entry: HistoryEntry, + entry: DbThreadMetadata, thread_view: WeakEntity, selected: bool, hovered: bool, @@ -627,7 +631,7 @@ pub struct AcpHistoryEntryElement { } impl AcpHistoryEntryElement { - pub fn new(entry: HistoryEntry, thread_view: WeakEntity) -> Self { + pub fn new(entry: DbThreadMetadata, thread_view: WeakEntity) -> Self { Self { entry, thread_view, @@ -650,9 +654,9 @@ impl AcpHistoryEntryElement { impl RenderOnce for AcpHistoryEntryElement { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let id = self.entry.id(); - let title = self.entry.title(); - let timestamp = self.entry.updated_at(); + let id = ElementId::Name(self.entry.id.0.clone().into()); + let title = thread_title(&self.entry).clone(); + let timestamp = self.entry.updated_at; let formatted_time = { let now = chrono::Utc::now(); @@ -720,31 +724,10 @@ impl RenderOnce for AcpHistoryEntryElement { .upgrade() .and_then(|view| view.read(cx).workspace().upgrade()) { - match &entry { - HistoryEntry::AcpThread(thread_metadata) => { - if let Some(panel) = workspace.read(cx).panel::(cx) { - panel.update(cx, |panel, cx| { - panel.load_agent_thread( - thread_metadata.clone(), - window, - cx, - ); - }); - } - } - HistoryEntry::TextThread(text_thread) => { - if let Some(panel) = workspace.read(cx).panel::(cx) { - panel.update(cx, |panel, cx| { - panel - .open_saved_text_thread( - text_thread.path.clone(), - window, - cx, - ) - .detach_and_log_err(cx); - }); - } - } + if let Some(panel) = workspace.read(cx).panel::(cx) { + panel.update(cx, |panel, cx| { + panel.load_agent_thread(entry.clone(), window, cx); + }); } } } diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index c0206285a1ea3ef8e0ee742badbef197dee238a6..547b0d4dde98cc79d5becda6c9ee6daa428e25d5 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -5,9 +5,7 @@ use acp_thread::{ }; use acp_thread::{AgentConnection, Plan}; use action_log::{ActionLog, ActionLogTelemetry}; -use agent::{ - DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer, SharedThread, -}; +use agent::{DbThreadMetadata, NativeAgentServer, SharedThread, ThreadStore}; use agent_client_protocol::{self as acp, PromptCapabilities}; use agent_servers::{AgentServer, AgentServerDelegate}; use agent_settings::{AgentProfileId, AgentSettings, CompletionMode}; @@ -312,7 +310,7 @@ pub struct AcpThreadView { project: Entity, thread_state: ThreadState, login: Option, - history_store: Entity, + thread_store: Entity, hovered_recent_history_item: Option, entry_view_state: Entity, message_editor: Entity, @@ -394,7 +392,7 @@ impl AcpThreadView { summarize_thread: Option, workspace: WeakEntity, project: Entity, - history_store: Entity, + thread_store: Entity, prompt_store: Option>, track_load_event: bool, window: &mut Window, @@ -415,7 +413,7 @@ impl AcpThreadView { let mut editor = MessageEditor::new( workspace.clone(), project.downgrade(), - history_store.clone(), + thread_store.clone(), prompt_store.clone(), prompt_capabilities.clone(), available_commands.clone(), @@ -440,7 +438,7 @@ impl AcpThreadView { EntryViewState::new( workspace.clone(), project.downgrade(), - history_store.clone(), + thread_store.clone(), prompt_store.clone(), prompt_capabilities.clone(), available_commands.clone(), @@ -515,7 +513,7 @@ impl AcpThreadView { available_commands, editor_expanded: false, should_be_following: false, - history_store, + thread_store, hovered_recent_history_item: None, is_loading_contents: false, _subscriptions: subscriptions, @@ -684,15 +682,6 @@ impl AcpThreadView { ); }); - if let Some(resume) = resume_thread { - this.history_store.update(cx, |history, cx| { - history.push_recently_opened_entry( - HistoryEntryId::AcpThread(resume.id), - cx, - ); - }); - } - AgentDiff::set_active_thread(&workspace, thread.clone(), window, cx); // Check for config options first @@ -1060,7 +1049,7 @@ impl AcpThreadView { }; let client = self.project.read(cx).client(); - let history_store = self.history_store.clone(); + let thread_store = self.thread_store.clone(); let session_id = thread.read(cx).id().clone(); cx.spawn_in(window, async move |this, cx| { @@ -1074,7 +1063,7 @@ impl AcpThreadView { let db_thread = shared_thread.to_db_thread(); - history_store + thread_store .update(&mut cx.clone(), |store, cx| { store.save_thread(session_id.clone(), db_thread, cx) }) @@ -1282,13 +1271,6 @@ impl AcpThreadView { return; } - self.history_store.update(cx, |history, cx| { - history.push_recently_opened_entry( - HistoryEntryId::AcpThread(thread.read(cx).session_id().clone()), - cx, - ); - }); - if thread.read(cx).status() != ThreadStatus::Idle { self.stop_current_and_send_new_message(window, cx); return; @@ -4070,15 +4052,13 @@ impl AcpThreadView { .clone() .downcast::() .is_some() - && self - .history_store - .update(cx, |history_store, cx| !history_store.is_empty(cx)); + && !self.thread_store.read(cx).is_empty(); v_flex() .size_full() .when(render_history, |this| { - let recent_history: Vec<_> = self.history_store.update(cx, |history_store, _| { - history_store.entries().take(3).collect() + let recent_history: Vec<_> = self.thread_store.update(cx, |thread_store, _| { + thread_store.entries().take(3).collect() }); this.justify_end().child( v_flex() @@ -6881,17 +6861,10 @@ impl AcpThreadView { })) } - pub fn delete_history_entry(&mut self, entry: HistoryEntry, cx: &mut Context) { - let task = match entry { - HistoryEntry::AcpThread(thread) => self.history_store.update(cx, |history, cx| { - history.delete_thread(thread.id.clone(), cx) - }), - HistoryEntry::TextThread(text_thread) => { - self.history_store.update(cx, |history, cx| { - history.delete_text_thread(text_thread.path.clone(), cx) - }) - } - }; + pub fn delete_history_entry(&mut self, entry: DbThreadMetadata, cx: &mut Context) { + let task = self + .thread_store + .update(cx, |store, cx| store.delete_thread(entry.id.clone(), cx)); task.detach_and_log_err(cx); } @@ -7267,7 +7240,6 @@ fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { pub(crate) mod tests { use acp_thread::StubAgentConnection; use agent_client_protocol::SessionId; - use assistant_text_thread::TextThreadStore; use editor::MultiBufferOffset; use fs::FakeFs; use gpui::{EventEmitter, TestAppContext, VisualTestContext}; @@ -7573,10 +7545,7 @@ pub(crate) mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let text_thread_store = - cx.update(|_window, cx| cx.new(|cx| TextThreadStore::fake(project.clone(), cx))); - let history_store = - cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(text_thread_store, cx))); + let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let thread_view = cx.update(|window, cx| { cx.new(|cx| { @@ -7586,7 +7555,7 @@ pub(crate) mod tests { None, workspace.downgrade(), project, - history_store, + thread_store, None, false, window, @@ -7842,10 +7811,7 @@ pub(crate) mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let text_thread_store = - cx.update(|_window, cx| cx.new(|cx| TextThreadStore::fake(project.clone(), cx))); - let history_store = - cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(text_thread_store, cx))); + let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let connection = Rc::new(StubAgentConnection::new()); let thread_view = cx.update(|window, cx| { @@ -7856,7 +7822,7 @@ pub(crate) mod tests { None, workspace.downgrade(), project.clone(), - history_store.clone(), + thread_store.clone(), None, false, window, diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 124f703ec4d0b8056a84be196a3c5fc916adf842..49fd82f5b714e352f7c7465833e303042494b70f 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1,7 +1,7 @@ use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration}; use acp_thread::AcpThread; -use agent::{ContextServerRegistry, DbThreadMetadata, HistoryEntry, HistoryStore}; +use agent::{ContextServerRegistry, DbThreadMetadata, ThreadStore}; use agent_servers::AgentServer; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use project::{ @@ -30,6 +30,7 @@ use crate::{ use crate::{ ExpandMessageEditor, acp::{AcpThreadHistory, ThreadHistoryEvent}, + text_thread_history::{TextThreadHistory, TextThreadHistoryEvent}, }; use crate::{ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary}; use agent_settings::AgentSettings; @@ -74,6 +75,8 @@ use zed_actions::{ }; const AGENT_PANEL_KEY: &str = "agent_panel"; +const RECENTLY_UPDATED_MENU_LIMIT: usize = 6; +const DEFAULT_THREAD_TITLE: &str = "New Thread"; #[derive(Serialize, Deserialize, Debug)] struct SerializedAgentPanel { @@ -209,6 +212,12 @@ pub fn init(cx: &mut App) { .detach(); } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HistoryKind { + AgentThreads, + TextThreads, +} + enum ActiveView { ExternalAgentThread { thread_view: Entity, @@ -219,7 +228,9 @@ enum ActiveView { buffer_search_bar: Entity, _subscriptions: Vec, }, - History, + History { + kind: HistoryKind, + }, Configuration, } @@ -280,7 +291,7 @@ impl From for AgentType { impl ActiveView { pub fn which_font_size_used(&self) -> WhichFontSize { match self { - ActiveView::ExternalAgentThread { .. } | ActiveView::History => { + ActiveView::ExternalAgentThread { .. } | ActiveView::History { .. } => { WhichFontSize::AgentFont } ActiveView::TextThread { .. } => WhichFontSize::BufferFont, @@ -291,7 +302,7 @@ impl ActiveView { fn native_agent( fs: Arc, prompt_store: Option>, - history_store: Entity, + thread_store: Entity, project: Entity, workspace: WeakEntity, window: &mut Window, @@ -299,12 +310,12 @@ impl ActiveView { ) -> Self { let thread_view = cx.new(|cx| { crate::acp::AcpThreadView::new( - ExternalAgent::NativeAgent.server(fs, history_store.clone()), + ExternalAgent::NativeAgent.server(fs, thread_store.clone()), None, None, workspace, project, - history_store, + thread_store, prompt_store, false, window, @@ -317,7 +328,6 @@ impl ActiveView { pub fn text_thread( text_thread_editor: Entity, - acp_history_store: Entity, language_registry: Arc, window: &mut Window, cx: &mut App, @@ -383,19 +393,7 @@ impl ActiveView { editor.set_text(summary, window, cx); }) } - TextThreadEvent::PathChanged { old_path, new_path } => { - acp_history_store.update(cx, |history_store, cx| { - if let Some(old_path) = old_path { - history_store - .replace_recently_opened_text_thread(old_path, new_path, cx); - } else { - history_store.push_recently_opened_entry( - agent::HistoryEntryId::TextThread(new_path.clone()), - cx, - ); - } - }); - } + TextThreadEvent::PathChanged { .. } => {} _ => {} } }), @@ -424,7 +422,8 @@ pub struct AgentPanel { fs: Arc, language_registry: Arc, acp_history: Entity, - history_store: Entity, + text_thread_history: Entity, + thread_store: Entity, text_thread_store: Entity, prompt_store: Option>, context_server_registry: Entity, @@ -543,13 +542,15 @@ impl AgentPanel { let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let history_store = cx.new(|cx| agent::HistoryStore::new(text_thread_store.clone(), cx)); - let acp_history = cx.new(|cx| AcpThreadHistory::new(history_store.clone(), window, cx)); + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + let acp_history = cx.new(|cx| AcpThreadHistory::new(thread_store.clone(), window, cx)); + let text_thread_history = + cx.new(|cx| TextThreadHistory::new(text_thread_store.clone(), window, cx)); cx.subscribe_in( &acp_history, window, |this, _, event, window, cx| match event { - ThreadHistoryEvent::Open(HistoryEntry::AcpThread(thread)) => { + ThreadHistoryEvent::Open(thread) => { this.external_thread( Some(crate::ExternalAgent::NativeAgent), Some(thread.clone()), @@ -558,7 +559,14 @@ impl AgentPanel { cx, ); } - ThreadHistoryEvent::Open(HistoryEntry::TextThread(thread)) => { + }, + ) + .detach(); + cx.subscribe_in( + &text_thread_history, + window, + |this, _, event, window, cx| match event { + TextThreadHistoryEvent::Open(thread) => { this.open_saved_text_thread(thread.path.clone(), window, cx) .detach_and_log_err(cx); } @@ -571,7 +579,7 @@ impl AgentPanel { DefaultView::Thread => ActiveView::native_agent( fs.clone(), prompt_store.clone(), - history_store.clone(), + thread_store.clone(), project.clone(), workspace.clone(), window, @@ -593,13 +601,7 @@ impl AgentPanel { editor.insert_default_prompt(window, cx); editor }); - ActiveView::text_thread( - text_thread_editor, - history_store.clone(), - language_registry.clone(), - window, - cx, - ) + ActiveView::text_thread(text_thread_editor, language_registry.clone(), window, cx) } }; @@ -610,11 +612,14 @@ impl AgentPanel { let agent_navigation_menu = ContextMenu::build_persistent(window, cx, move |mut menu, _window, cx| { if let Some(panel) = panel.upgrade() { - menu = Self::populate_recently_opened_menu_section(menu, panel, cx); + if let Some(kind) = panel.read(cx).history_kind_for_selected_agent() { + menu = + Self::populate_recently_updated_menu_section(menu, panel, kind, cx); + menu = menu.action("View All", Box::new(OpenHistory)); + } } menu = menu - .action("View All", Box::new(OpenHistory)) .fixed_width(px(320.).into()) .keep_open_on_confirm(false) .key_context("NavigationMenu"); @@ -691,7 +696,8 @@ impl AgentPanel { pending_serialization: None, onboarding, acp_history, - history_store, + text_thread_history, + thread_store, selected_agent: AgentType::default(), loading: false, show_trust_workspace_message: false, @@ -720,8 +726,8 @@ impl AgentPanel { &self.prompt_store } - pub fn thread_store(&self) -> &Entity { - &self.history_store + pub fn thread_store(&self) -> &Entity { + &self.thread_store } pub fn open_thread( @@ -765,7 +771,9 @@ impl AgentPanel { fn active_thread_view(&self) -> Option<&Entity> { match &self.active_view { ActiveView::ExternalAgentThread { thread_view, .. } => Some(thread_view), - ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None, + ActiveView::TextThread { .. } + | ActiveView::History { .. } + | ActiveView::Configuration => None, } } @@ -780,7 +788,7 @@ impl AgentPanel { cx: &mut Context, ) { let Some(thread) = self - .history_store + .thread_store .read(cx) .thread_from_session_id(&action.from_session_id) else { @@ -828,7 +836,6 @@ impl AgentPanel { self.set_active_view( ActiveView::text_thread( text_thread_editor.clone(), - self.history_store.clone(), self.language_registry.clone(), window, cx, @@ -861,7 +868,7 @@ impl AgentPanel { } let loading = self.loading; - let history = self.history_store.clone(); + let thread_store = self.thread_store.clone(); cx.spawn_in(window, async move |this, cx| { let ext_agent = match agent_choice { @@ -902,7 +909,7 @@ impl AgentPanel { } }; - let server = ext_agent.server(fs, history); + let server = ext_agent.server(fs, thread_store); this.update_in(cx, |agent_panel, window, cx| { agent_panel._external_thread( server, @@ -955,14 +962,32 @@ impl AgentPanel { } } + fn history_kind_for_selected_agent(&self) -> Option { + match self.selected_agent { + AgentType::NativeAgent => Some(HistoryKind::AgentThreads), + AgentType::TextThread => Some(HistoryKind::TextThreads), + AgentType::Gemini + | AgentType::ClaudeCode + | AgentType::Codex + | AgentType::Custom { .. } => None, + } + } + fn open_history(&mut self, window: &mut Window, cx: &mut Context) { - if matches!(self.active_view, ActiveView::History) { - if let Some(previous_view) = self.previous_view.take() { - self.set_active_view(previous_view, true, window, cx); + let Some(kind) = self.history_kind_for_selected_agent() else { + return; + }; + + if let ActiveView::History { kind: active_kind } = self.active_view { + if active_kind == kind { + if let Some(previous_view) = self.previous_view.take() { + self.set_active_view(previous_view, true, window, cx); + } + return; } - } else { - self.set_active_view(ActiveView::History, true, window, cx); } + + self.set_active_view(ActiveView::History { kind }, true, window, cx); cx.notify(); } @@ -973,8 +998,8 @@ impl AgentPanel { cx: &mut Context, ) -> Task> { let text_thread_task = self - .history_store - .update(cx, |store, cx| store.load_text_thread(path, cx)); + .text_thread_store + .update(cx, |store, cx| store.open_local(path, cx)); cx.spawn_in(window, async move |this, cx| { let text_thread = text_thread_task.await?; this.update_in(cx, |this, window, cx| { @@ -1010,13 +1035,7 @@ impl AgentPanel { } self.set_active_view( - ActiveView::text_thread( - editor, - self.history_store.clone(), - self.language_registry.clone(), - window, - cx, - ), + ActiveView::text_thread(editor, self.language_registry.clone(), window, cx), true, window, cx, @@ -1025,7 +1044,7 @@ impl AgentPanel { pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context) { match self.active_view { - ActiveView::Configuration | ActiveView::History => { + ActiveView::Configuration | ActiveView::History { .. } => { if let Some(previous_view) = self.previous_view.take() { self.active_view = previous_view; @@ -1038,7 +1057,7 @@ impl AgentPanel { } => { text_thread_editor.focus_handle(cx).focus(window, cx); } - ActiveView::History | ActiveView::Configuration => {} + ActiveView::History { .. } | ActiveView::Configuration => {} } } cx.notify(); @@ -1053,6 +1072,9 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { + if self.history_kind_for_selected_agent().is_none() { + return; + } self.agent_navigation_menu_handle.toggle(window, cx); } @@ -1206,7 +1228,9 @@ impl AgentPanel { }) .detach_and_log_err(cx); } - ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} + ActiveView::TextThread { .. } + | ActiveView::History { .. } + | ActiveView::Configuration => {} } } @@ -1284,8 +1308,8 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - let current_is_history = matches!(self.active_view, ActiveView::History); - let new_is_history = matches!(new_view, ActiveView::History); + let current_is_history = matches!(self.active_view, ActiveView::History { .. }); + let new_is_history = matches!(new_view, ActiveView::History { .. }); let current_is_config = matches!(self.active_view, ActiveView::Configuration); let new_is_config = matches!(new_view, ActiveView::Configuration); @@ -1294,18 +1318,9 @@ impl AgentPanel { let new_is_special = new_is_history || new_is_config; match &new_view { - ActiveView::TextThread { - text_thread_editor, .. - } => self.history_store.update(cx, |store, cx| { - if let Some(path) = text_thread_editor.read(cx).text_thread().read(cx).path() { - store.push_recently_opened_entry( - agent::HistoryEntryId::TextThread(path.clone()), - cx, - ) - } - }), + ActiveView::TextThread { .. } => {} ActiveView::ExternalAgentThread { .. } => {} - ActiveView::History | ActiveView::Configuration => {} + ActiveView::History { .. } | ActiveView::Configuration => {} } if current_is_special && !new_is_special { @@ -1324,71 +1339,96 @@ impl AgentPanel { } } - fn populate_recently_opened_menu_section( + fn populate_recently_updated_menu_section( mut menu: ContextMenu, panel: Entity, + kind: HistoryKind, cx: &mut Context, ) -> ContextMenu { - let entries = panel - .read(cx) - .history_store - .read(cx) - .recently_opened_entries(cx); + match kind { + HistoryKind::AgentThreads => { + let entries = panel + .read(cx) + .thread_store + .read(cx) + .entries() + .take(RECENTLY_UPDATED_MENU_LIMIT) + .collect::>(); - if entries.is_empty() { - return menu; - } + if entries.is_empty() { + return menu; + } - menu = menu.header("Recently Opened"); + menu = menu.header("Recently Updated"); - for entry in entries { - let title = entry.title().clone(); + for entry in entries { + let title = if entry.title.is_empty() { + SharedString::new_static(DEFAULT_THREAD_TITLE) + } else { + entry.title.clone() + }; - menu = menu.entry_with_end_slot_on_hover( - title, - None, - { - let panel = panel.downgrade(); - let entry = entry.clone(); - move |window, cx| { + menu = menu.entry(title, None, { + let panel = panel.downgrade(); let entry = entry.clone(); - panel - .update(cx, move |this, cx| match &entry { - agent::HistoryEntry::AcpThread(entry) => this.external_thread( - Some(ExternalAgent::NativeAgent), - Some(entry.clone()), - None, - window, - cx, - ), - agent::HistoryEntry::TextThread(entry) => this - .open_saved_text_thread(entry.path.clone(), window, cx) - .detach_and_log_err(cx), - }) - .ok(); - } - }, - IconName::Close, - "Close Entry".into(), - { - let panel = panel.downgrade(); - let id = entry.id(); - move |_window, cx| { - panel - .update(cx, |this, cx| { - this.history_store.update(cx, |history_store, cx| { - history_store.remove_recently_opened_entry(&id, cx); - }); - }) - .ok(); - } - }, - ); - } + move |window, cx| { + let entry = entry.clone(); + panel + .update(cx, move |this, cx| { + this.external_thread( + Some(ExternalAgent::NativeAgent), + Some(entry.clone()), + None, + window, + cx, + ); + }) + .ok(); + } + }); + } + } + HistoryKind::TextThreads => { + let entries = panel + .read(cx) + .text_thread_store + .read(cx) + .ordered_text_threads() + .take(RECENTLY_UPDATED_MENU_LIMIT) + .cloned() + .collect::>(); + + if entries.is_empty() { + return menu; + } + + menu = menu.header("Recently Updated"); - menu = menu.separator(); + for entry in entries { + let title = if entry.title.is_empty() { + SharedString::new_static(DEFAULT_THREAD_TITLE) + } else { + entry.title.clone() + }; - menu + menu = menu.entry(title, None, { + let panel = panel.downgrade(); + let entry = entry.clone(); + move |window, cx| { + let path = entry.path.clone(); + panel + .update(cx, move |this, cx| { + this.open_saved_text_thread(path.clone(), window, cx) + .detach_and_log_err(cx); + }) + .ok(); + } + }); + } + } + } + + menu.separator() } pub fn selected_agent(&self) -> AgentType { @@ -1506,7 +1546,7 @@ impl AgentPanel { summarize_thread, workspace.clone(), project, - self.history_store.clone(), + self.thread_store.clone(), self.prompt_store.clone(), !loading, window, @@ -1527,7 +1567,10 @@ impl Focusable for AgentPanel { fn focus_handle(&self, cx: &App) -> FocusHandle { match &self.active_view { ActiveView::ExternalAgentThread { thread_view, .. } => thread_view.focus_handle(cx), - ActiveView::History => self.acp_history.focus_handle(cx), + ActiveView::History { kind } => match kind { + HistoryKind::AgentThreads => self.acp_history.focus_handle(cx), + HistoryKind::TextThreads => self.text_thread_history.focus_handle(cx), + }, ActiveView::TextThread { text_thread_editor, .. } => text_thread_editor.focus_handle(cx), @@ -1738,7 +1781,13 @@ impl AgentPanel { .into_any_element(), } } - ActiveView::History => Label::new("History").truncate().into_any_element(), + ActiveView::History { kind } => { + let title = match kind { + HistoryKind::AgentThreads => "History", + HistoryKind::TextThreads => "Text Threads", + }; + Label::new(title).truncate().into_any_element() + } ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(), }; @@ -1955,7 +2004,7 @@ impl AgentPanel { { move |_window, cx| { Tooltip::for_action_in( - "Toggle Recent Threads", + "Toggle Recently Updated Threads", &ToggleNavigationMenu, &focus_handle, cx, @@ -2018,7 +2067,9 @@ impl AgentPanel { ActiveView::ExternalAgentThread { thread_view } => { thread_view.read(cx).as_native_thread(cx) } - ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None, + ActiveView::TextThread { .. } + | ActiveView::History { .. } + | ActiveView::Configuration => None, }; let new_thread_menu = PopoverMenu::new("new_thread_menu") @@ -2343,6 +2394,8 @@ impl AgentPanel { selected_agent.into_any_element() }; + let show_history_menu = self.history_kind_for_selected_agent().is_some(); + h_flex() .id("agent-panel-toolbar") .h(Tab::container_height(cx)) @@ -2359,7 +2412,7 @@ impl AgentPanel { .gap(DynamicSpacing::Base04.rems(cx)) .pl(DynamicSpacing::Base04.rems(cx)) .child(match &self.active_view { - ActiveView::History | ActiveView::Configuration => { + ActiveView::History { .. } | ActiveView::Configuration => { self.render_toolbar_back_button(cx).into_any_element() } _ => selected_agent.into_any_element(), @@ -2373,11 +2426,13 @@ impl AgentPanel { .pl(DynamicSpacing::Base04.rems(cx)) .pr(DynamicSpacing::Base06.rems(cx)) .child(new_thread_menu) - .child(self.render_recent_entries_menu( - IconName::MenuAltTemp, - Corner::TopRight, - cx, - )) + .when(show_history_menu, |this| { + this.child(self.render_recent_entries_menu( + IconName::MenuAltTemp, + Corner::TopRight, + cx, + )) + }) .child(self.render_panel_options_menu(window, cx)), ) } @@ -2400,7 +2455,7 @@ impl AgentPanel { } } ActiveView::ExternalAgentThread { .. } - | ActiveView::History + | ActiveView::History { .. } | ActiveView::Configuration => return false, } @@ -2433,14 +2488,14 @@ impl AgentPanel { } match &self.active_view { - ActiveView::History | ActiveView::Configuration => false, + ActiveView::History { .. } | ActiveView::Configuration => false, ActiveView::ExternalAgentThread { thread_view, .. } if thread_view.read(cx).as_native_thread(cx).is_none() => { false } _ => { - let history_is_empty = self.history_store.read(cx).is_empty(cx); + let history_is_empty = self.thread_store.read(cx).is_empty(); let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx) .visible_providers() @@ -2703,7 +2758,7 @@ impl AgentPanel { ); }); } - ActiveView::History | ActiveView::Configuration => {} + ActiveView::History { .. } | ActiveView::Configuration => {} } } @@ -2745,7 +2800,7 @@ impl AgentPanel { match &self.active_view { ActiveView::ExternalAgentThread { .. } => key_context.add("acp_thread"), ActiveView::TextThread { .. } => key_context.add("text_thread"), - ActiveView::History | ActiveView::Configuration => {} + ActiveView::History { .. } | ActiveView::Configuration => {} } key_context } @@ -2797,7 +2852,10 @@ impl Render for AgentPanel { ActiveView::ExternalAgentThread { thread_view, .. } => parent .child(thread_view.clone()) .child(self.render_drag_target(cx)), - ActiveView::History => parent.child(self.acp_history.clone()), + ActiveView::History { kind } => match kind { + HistoryKind::AgentThreads => parent.child(self.acp_history.clone()), + HistoryKind::TextThreads => parent.child(self.text_thread_history.clone()), + }, ActiveView::TextThread { text_thread_editor, buffer_search_bar, diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 703f3bab7b924a7cbb1df16d65b9d285ab9296e5..bd8f9eb6ecda35ee2d3804406c07fb7637ee5557 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -18,6 +18,7 @@ mod slash_command_picker; mod terminal_codegen; mod terminal_inline_assistant; mod text_thread_editor; +mod text_thread_history; mod ui; use std::rc::Rc; @@ -62,8 +63,6 @@ actions!( ToggleNavigationMenu, /// Toggles the options menu for agent settings and preferences. ToggleOptionsMenu, - /// Deletes the recently opened thread from history. - DeleteRecentlyOpenThread, /// Toggles the profile or mode selector for switching between agent profiles. ToggleProfileSelector, /// Cycles through available session modes. @@ -170,13 +169,13 @@ impl ExternalAgent { pub fn server( &self, fs: Arc, - history: Entity, + thread_store: Entity, ) -> Rc { match self { Self::Gemini => Rc::new(agent_servers::Gemini), Self::ClaudeCode => Rc::new(agent_servers::ClaudeCode), Self::Codex => Rc::new(agent_servers::Codex), - Self::NativeAgent => Rc::new(agent::NativeAgentServer::new(fs, history)), + Self::NativeAgent => Rc::new(agent::NativeAgentServer::new(fs, thread_store)), Self::Custom { name } => Rc::new(agent_servers::CustomAgentServer::new(name.clone())), } } diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 1bc9548aa6b327f57c01a36eda5f851e669a7741..c87f8a65c83e6464ecd8260d582c3eaff3f18197 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -5,13 +5,13 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool; use acp_thread::MentionUri; -use agent::{HistoryEntry, HistoryStore}; +use agent::{DbThreadMetadata, ThreadStore}; use anyhow::Result; use editor::{ CompletionProvider, Editor, ExcerptId, code_context_menus::COMPLETION_MENU_MAX_WIDTH, }; use fuzzy::{PathMatch, StringMatch, StringMatchCandidate}; -use gpui::{App, Entity, Task, WeakEntity}; +use gpui::{App, Entity, SharedString, Task, WeakEntity}; use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId}; use lsp::CompletionContext; use ordered_float::OrderedFloat; @@ -132,8 +132,8 @@ impl PromptContextType { pub(crate) enum Match { File(FileMatch), Symbol(SymbolMatch), - Thread(HistoryEntry), - RecentThread(HistoryEntry), + Thread(DbThreadMetadata), + RecentThread(DbThreadMetadata), Fetch(SharedString), Rules(RulesContextEntry), Entry(EntryMatch), @@ -158,6 +158,14 @@ pub struct EntryMatch { entry: PromptContextEntry, } +fn thread_title(thread: &DbThreadMetadata) -> SharedString { + if thread.title.is_empty() { + "New Thread".into() + } else { + thread.title.clone() + } +} + #[derive(Debug, Clone)] pub struct RulesContextEntry { pub prompt_id: UserPromptId, @@ -186,7 +194,7 @@ pub struct PromptCompletionProvider { source: Arc, editor: WeakEntity, mention_set: Entity, - history_store: Entity, + thread_store: Entity, prompt_store: Option>, workspace: WeakEntity, } @@ -196,7 +204,7 @@ impl PromptCompletionProvider { source: T, editor: WeakEntity, mention_set: Entity, - history_store: Entity, + thread_store: Entity, prompt_store: Option>, workspace: WeakEntity, ) -> Self { @@ -205,7 +213,7 @@ impl PromptCompletionProvider { editor, mention_set, workspace, - history_store, + thread_store, prompt_store, } } @@ -246,7 +254,7 @@ impl PromptCompletionProvider { } fn completion_for_thread( - thread_entry: HistoryEntry, + thread_entry: DbThreadMetadata, source_range: Range, recent: bool, source: Arc, @@ -255,7 +263,11 @@ impl PromptCompletionProvider { workspace: Entity, cx: &mut App, ) -> Completion { - let uri = thread_entry.mention_uri(); + let title = thread_title(&thread_entry); + let uri = MentionUri::Thread { + id: thread_entry.id, + name: title.to_string(), + }; let icon_for_completion = if recent { IconName::HistoryRerun.path().into() @@ -269,7 +281,7 @@ impl PromptCompletionProvider { Completion { replace_range: source_range.clone(), new_text, - label: CodeLabel::plain(thread_entry.title().to_string(), None), + label: CodeLabel::plain(title.to_string(), None), documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, @@ -277,7 +289,7 @@ impl PromptCompletionProvider { snippet_deduplication_key: None, icon_path: Some(icon_for_completion), confirm: Some(confirm_completion_callback( - thread_entry.title().clone(), + title, source_range.start, new_text_len - 1, uri, @@ -637,7 +649,7 @@ impl PromptCompletionProvider { Some(PromptContextType::Thread) => { let search_threads_task = - search_threads(query, cancellation_flag, &self.history_store, cx); + search_threads(query, cancellation_flag, &self.thread_store, cx); cx.background_spawn(async move { search_threads_task .await @@ -800,11 +812,16 @@ impl PromptCompletionProvider { if self.source.supports_context(PromptContextType::Thread, cx) { const RECENT_COUNT: usize = 2; let threads = self - .history_store + .thread_store .read(cx) - .recently_opened_entries(cx) - .into_iter() - .filter(|thread| !mentions.contains(&thread.mention_uri())) + .entries() + .filter(|thread| { + let uri = MentionUri::Thread { + id: thread.id.clone(), + name: thread_title(thread).to_string(), + }; + !mentions.contains(&uri) + }) .take(RECENT_COUNT) .collect::>(); @@ -1529,9 +1546,9 @@ pub(crate) fn search_symbols( pub(crate) fn search_threads( query: String, cancellation_flag: Arc, - thread_store: &Entity, + thread_store: &Entity, cx: &mut App, -) -> Task> { +) -> Task> { let threads = thread_store.read(cx).entries().collect(); if query.is_empty() { return Task::ready(threads); @@ -1542,7 +1559,7 @@ pub(crate) fn search_threads( let candidates = threads .iter() .enumerate() - .map(|(id, thread)| StringMatchCandidate::new(id, thread.title())) + .map(|(id, thread)| StringMatchCandidate::new(id, thread_title(thread).as_ref())) .collect::>(); let matches = fuzzy::match_strings( &candidates, diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index de2f1c360452d4698438968f8df5bc0e90c11388..d2e593b43e8337bfba1dddd95f12a4fb8cae2af7 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -15,7 +15,7 @@ use crate::{ inline_prompt_editor::{CodegenStatus, InlineAssistId, PromptEditor, PromptEditorEvent}, terminal_inline_assistant::TerminalInlineAssistant, }; -use agent::HistoryStore; +use agent::ThreadStore; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; use collections::{HashMap, HashSet, VecDeque, hash_map}; @@ -468,7 +468,7 @@ impl InlineAssistant { editor: &Entity, workspace: WeakEntity, project: WeakEntity, - thread_store: Entity, + thread_store: Entity, prompt_store: Option>, initial_prompt: Option, window: &mut Window, @@ -605,7 +605,7 @@ impl InlineAssistant { editor: &Entity, workspace: WeakEntity, project: WeakEntity, - thread_store: Entity, + thread_store: Entity, prompt_store: Option>, initial_prompt: Option, window: &mut Window, @@ -648,7 +648,7 @@ impl InlineAssistant { initial_transaction_id: Option, focus: bool, workspace: Entity, - thread_store: Entity, + thread_store: Entity, prompt_store: Option>, window: &mut Window, cx: &mut App, @@ -2031,8 +2031,7 @@ pub mod test { use std::sync::Arc; - use agent::HistoryStore; - use assistant_text_thread::TextThreadStore; + use agent::ThreadStore; use client::{Client, UserStore}; use editor::{Editor, MultiBuffer, MultiBufferOffset}; use fs::FakeFs; @@ -2131,8 +2130,7 @@ pub mod test { }) }); - let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); - let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let thread_store = cx.new(|cx| ThreadStore::new(cx)); // Add editor to workspace workspace.update(cx, |workspace, cx| { @@ -2146,8 +2144,8 @@ pub mod test { &editor, workspace.downgrade(), project.downgrade(), - history_store, // thread_store - None, // prompt_store + thread_store, + None, Some(prompt), window, cx, diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index 312e5a077ad7b9ef3e5a0132b77c3598b267a66e..2ae2e867c2c0685557798d457f74322cb035cf4f 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -1,4 +1,4 @@ -use agent::HistoryStore; +use agent::ThreadStore; use collections::{HashMap, VecDeque}; use editor::actions::Paste; use editor::code_context_menus::CodeContextMenu; @@ -60,7 +60,7 @@ pub struct PromptEditor { pub editor: Entity, mode: PromptEditorMode, mention_set: Entity, - history_store: Entity, + thread_store: Entity, prompt_store: Option>, workspace: WeakEntity, model_selector: Entity, @@ -331,7 +331,7 @@ impl PromptEditor { PromptEditorCompletionProviderDelegate, cx.weak_entity(), self.mention_set.clone(), - self.history_store.clone(), + self.thread_store.clone(), self.prompt_store.clone(), self.workspace.clone(), )))); @@ -1209,7 +1209,7 @@ impl PromptEditor { codegen: Entity, session_id: Uuid, fs: Arc, - history_store: Entity, + thread_store: Entity, prompt_store: Option>, project: WeakEntity, workspace: WeakEntity, @@ -1250,14 +1250,14 @@ impl PromptEditor { }); let mention_set = - cx.new(|_cx| MentionSet::new(project, history_store.clone(), prompt_store.clone())); + cx.new(|_cx| MentionSet::new(project, thread_store.clone(), prompt_store.clone())); let model_selector_menu_handle = PopoverMenuHandle::default(); let mut this: PromptEditor = PromptEditor { editor: prompt_editor.clone(), mention_set, - history_store, + thread_store, prompt_store, workspace, model_selector: cx.new(|cx| { @@ -1367,7 +1367,7 @@ impl PromptEditor { codegen: Entity, session_id: Uuid, fs: Arc, - history_store: Entity, + thread_store: Entity, prompt_store: Option>, project: WeakEntity, workspace: WeakEntity, @@ -1403,14 +1403,14 @@ impl PromptEditor { }); let mention_set = - cx.new(|_cx| MentionSet::new(project, history_store.clone(), prompt_store.clone())); + cx.new(|_cx| MentionSet::new(project, thread_store.clone(), prompt_store.clone())); let model_selector_menu_handle = PopoverMenuHandle::default(); let mut this = Self { editor: prompt_editor.clone(), mention_set, - history_store, + thread_store, prompt_store, workspace, model_selector: cx.new(|cx| { diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 8160280be078412cf86e7f761b637451b4022ccb..395bbd8d7891b9e5d379c40972bbd9419305f4a1 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -1,5 +1,5 @@ use acp_thread::{MentionUri, selection_name}; -use agent::{HistoryStore, outline}; +use agent::{ThreadStore, outline}; use agent_client_protocol as acp; use agent_servers::{AgentServer, AgentServerDelegate}; use anyhow::{Context as _, Result, anyhow}; @@ -60,7 +60,7 @@ pub struct MentionImage { pub struct MentionSet { project: WeakEntity, - history_store: Entity, + thread_store: Entity, prompt_store: Option>, mentions: HashMap, } @@ -68,12 +68,12 @@ pub struct MentionSet { impl MentionSet { pub fn new( project: WeakEntity, - history_store: Entity, + thread_store: Entity, prompt_store: Option>, ) -> Self { Self { project, - history_store, + thread_store, prompt_store, mentions: HashMap::default(), } @@ -218,7 +218,9 @@ impl MentionSet { } MentionUri::Directory { .. } => Task::ready(Ok(Mention::Link)), MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx), - MentionUri::TextThread { path, .. } => self.confirm_mention_for_text_thread(path, cx), + MentionUri::TextThread { .. } => { + Task::ready(Err(anyhow!("Text thread mentions are no longer supported"))) + } MentionUri::File { abs_path } => { self.confirm_mention_for_file(abs_path, supports_images, cx) } @@ -477,7 +479,7 @@ impl MentionSet { let server = Rc::new(agent::NativeAgentServer::new( project.read(cx).fs().clone(), - self.history_store.clone(), + self.thread_store.clone(), )); let delegate = AgentServerDelegate::new( project.read(cx).agent_server_store().clone(), @@ -499,24 +501,6 @@ impl MentionSet { }) }) } - - fn confirm_mention_for_text_thread( - &mut self, - path: PathBuf, - cx: &mut Context, - ) -> Task> { - let text_thread_task = self.history_store.update(cx, |store, cx| { - store.load_text_thread(path.as_path().into(), cx) - }); - cx.spawn(async move |_, cx| { - let text_thread = text_thread_task.await?; - let xml = text_thread.update(cx, |text_thread, cx| text_thread.to_xml(cx)); - Ok(Mention::Text { - content: xml, - tracked_buffers: Vec::new(), - }) - }) - } } pub(crate) fn paste_images_as_context( diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index cacbc316bb84e74e5c369451791f777a9bf58e82..d8e18bd52ba89e7fbf4f3391e5c7ca3f042b289e 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -5,7 +5,7 @@ use crate::{ }, terminal_codegen::{CLEAR_INPUT, CodegenEvent, TerminalCodegen}, }; -use agent::HistoryStore; +use agent::ThreadStore; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; @@ -61,7 +61,7 @@ impl TerminalInlineAssistant { terminal_view: &Entity, workspace: WeakEntity, project: WeakEntity, - thread_store: Entity, + thread_store: Entity, prompt_store: Option>, initial_prompt: Option, window: &mut Window, diff --git a/crates/agent_ui/src/text_thread_history.rs b/crates/agent_ui/src/text_thread_history.rs new file mode 100644 index 0000000000000000000000000000000000000000..f96a1db2daccd5464bc831fffd034e6aff9d79fa --- /dev/null +++ b/crates/agent_ui/src/text_thread_history.rs @@ -0,0 +1,732 @@ +use crate::{RemoveHistory, RemoveSelectedThread}; +use assistant_text_thread::{SavedTextThreadMetadata, TextThreadStore}; +use chrono::{Datelike, Local, NaiveDate, TimeDelta, Utc}; +use editor::{Editor, EditorEvent}; +use fuzzy::StringMatchCandidate; +use gpui::{ + App, Entity, EventEmitter, FocusHandle, Focusable, Task, UniformListScrollHandle, Window, + uniform_list, +}; +use std::{fmt::Display, ops::Range}; +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: &SavedTextThreadMetadata) -> &SharedString { + if entry.title.is_empty() { + DEFAULT_TITLE + } else { + &entry.title + } +} + +pub struct TextThreadHistory { + pub(crate) text_thread_store: Entity, + 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<()>, + _subscriptions: Vec, +} + +enum ListItemType { + BucketSeparator(TimeBucket), + Entry { + entry: SavedTextThreadMetadata, + format: EntryTimeFormat, + }, + SearchResult { + entry: SavedTextThreadMetadata, + positions: Vec, + }, +} + +impl ListItemType { + fn history_entry(&self) -> Option<&SavedTextThreadMetadata> { + match self { + ListItemType::Entry { entry, .. } => Some(entry), + ListItemType::SearchResult { entry, .. } => Some(entry), + _ => None, + } + } +} + +pub enum TextThreadHistoryEvent { + Open(SavedTextThreadMetadata), +} + +impl EventEmitter for TextThreadHistory {} + +impl TextThreadHistory { + pub(crate) fn new( + text_thread_store: Entity, + 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 store_subscription = cx.observe(&text_thread_store, |this, _, cx| { + this.update_visible_items(true, cx); + }); + + let scroll_handle = UniformListScrollHandle::default(); + + let mut this = Self { + text_thread_store, + 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, store_subscription], + _update_task: Task::ready(()), + }; + this.update_visible_items(false, cx); + this + } + + fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { + let entries = self.text_thread_store.update(cx, |store, _| { + store.ordered_text_threads().cloned().collect::>() + }); + + 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.path == history_entry.path) + }) + .unwrap_or(0) + } else { + 0 + }; + + this.visible_items = new_visible_items; + this.set_selected_index(new_selected_index, Bias::Right, cx); + cx.notify(); + }) + .ok(); + }); + } + + 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_date = entry.mtime.naive_local().date(); + let entry_bucket = TimeBucket::from_dates(today, entry_date); + + 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<&SavedTextThreadMetadata> { + self.get_history_entry(self.selected_index) + } + + fn get_history_entry(&self, visible_items_ix: usize) -> Option<&SavedTextThreadMetadata> { + 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; + cx.notify(); + } + + 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_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); + } + } + + 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(TextThreadHistoryEvent::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 task = self + .text_thread_store + .update(cx, |store, cx| store.delete_local(entry.path.clone(), cx)); + task.detach_and_log_err(cx); + } + + fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.text_thread_store.update(cx, |store, cx| { + store.delete_all_local(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: &SavedTextThreadMetadata, + format: EntryTimeFormat, + ix: usize, + highlight_positions: Vec, + cx: &Context, + ) -> AnyElement { + let selected = ix == self.selected_index; + let hovered = Some(ix) == self.hovered_index; + let entry_time = entry.mtime.with_timezone(&Utc); + let timestamp = entry_time.timestamp(); + + let display_text = match format { + EntryTimeFormat::DateAndTime => { + let now = Utc::now(); + let duration = now.signed_duration_since(entry_time); + let days = duration.num_days(); + + format!("{}d", days) + } + EntryTimeFormat::TimeOnly => format.format_timestamp(timestamp, self.local_timezone), + }; + + let title = thread_title(entry).clone(); + let full_date = + EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone); + + 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, _, _window, cx| { + this.remove_thread(ix, cx); + cx.stop_propagation() + })), + ) + } else { + None + }) + .on_click(cx.listener(move |this, _, _window, cx| { + this.confirm_entry(ix, cx); + })), + ) + .into_any_element() + } +} + +impl Render for TextThreadHistory { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let has_no_history = !self.text_thread_store.read(cx).has_saved_text_threads(); + + v_flex() + .size_full() + .key_context("ThreadHistory") + .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(|this, _: &RemoveSelectedThread, window, cx| { + this.remove_selected_thread(&RemoveSelectedThread, window, cx); + })) + .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( + "text-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 text 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, + ); + })), + ), + ) + }), + ) + }) + } +} + +impl Focusable for TextThreadHistory { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.search_editor.focus_handle(cx) + } +} + +#[derive(Clone, Copy)] +pub enum EntryTimeFormat { + DateAndTime, + TimeOnly, +} + +impl EntryTimeFormat { + fn format_timestamp(self, timestamp: i64, timezone: UtcOffset) -> String { + let datetime = OffsetDateTime::from_unix_timestamp(timestamp) + .unwrap_or_else(|_| OffsetDateTime::now_utc()) + .to_offset(timezone); + + match self { + EntryTimeFormat::DateAndTime => datetime.format(&time::macros::format_description!( + "[month repr:short] [day], [year]" + )), + EntryTimeFormat::TimeOnly => { + datetime.format(&time::macros::format_description!("[hour]:[minute]")) + } + } + .unwrap_or_default() + } +} + +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::*; + + #[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); + + let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All); + } +} diff --git a/crates/agent_ui_v2/Cargo.toml b/crates/agent_ui_v2/Cargo.toml index 2b2cf337adf578432d594ce14f2f58e5911c45fb..28458a66ddc2d01f7747781b36646e125be98f8d 100644 --- a/crates/agent_ui_v2/Cargo.toml +++ b/crates/agent_ui_v2/Cargo.toml @@ -18,11 +18,11 @@ test-support = ["agent/test-support"] [dependencies] agent.workspace = true +agent-client-protocol.workspace = true agent_servers.workspace = true agent_settings.workspace = true agent_ui.workspace = true anyhow.workspace = true -assistant_text_thread.workspace = true chrono.workspace = true db.workspace = true editor.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 72886f87eca38c630ec29b9b410930f1d3936b50..1689a99711abaaa6eeca61ada35866bec9901ffa 100644 --- a/crates/agent_ui_v2/src/agent_thread_pane.rs +++ b/crates/agent_ui_v2/src/agent_thread_pane.rs @@ -1,4 +1,5 @@ -use agent::{HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer}; +use agent::{DbThreadMetadata, NativeAgentServer, ThreadStore}; +use agent_client_protocol as acp; use agent_servers::AgentServer; use agent_settings::AgentSettings; use agent_ui::acp::AcpThreadView; @@ -25,19 +26,11 @@ pub const DEFAULT_UTILITY_PANE_WIDTH: Pixels = gpui::px(400.0); #[derive(Serialize, Deserialize, Debug, Clone)] pub enum SerializedHistoryEntryId { AcpThread(String), - TextThread(String), } -impl From for SerializedHistoryEntryId { - fn from(id: HistoryEntryId) -> Self { - match id { - HistoryEntryId::AcpThread(session_id) => { - SerializedHistoryEntryId::AcpThread(session_id.0.to_string()) - } - HistoryEntryId::TextThread(path) => { - SerializedHistoryEntryId::TextThread(path.to_string_lossy().to_string()) - } - } +impl From for SerializedHistoryEntryId { + fn from(id: acp::SessionId) -> Self { + SerializedHistoryEntryId::AcpThread(id.0.to_string()) } } @@ -58,7 +51,7 @@ impl EventEmitter for AgentThreadPane {} struct ActiveThreadView { view: Entity, - thread_id: HistoryEntryId, + thread_id: acp::SessionId, _notify: Subscription, } @@ -82,7 +75,7 @@ impl AgentThreadPane { } } - pub fn thread_id(&self) -> Option { + pub fn thread_id(&self) -> Option { self.thread_view.as_ref().map(|tv| tv.thread_id.clone()) } @@ -96,23 +89,19 @@ impl AgentThreadPane { pub fn open_thread( &mut self, - entry: HistoryEntry, + entry: DbThreadMetadata, fs: Arc, workspace: WeakEntity, project: Entity, - history_store: Entity, + thread_store: Entity, prompt_store: Option>, window: &mut Window, cx: &mut Context, ) { - let thread_id = entry.id(); - - let resume_thread = match &entry { - HistoryEntry::AcpThread(thread) => Some(thread.clone()), - HistoryEntry::TextThread(_) => None, - }; + let thread_id = entry.id.clone(); + let resume_thread = Some(entry); - let agent: Rc = Rc::new(NativeAgentServer::new(fs, history_store.clone())); + let agent: Rc = Rc::new(NativeAgentServer::new(fs, thread_store.clone())); let thread_view = cx.new(|cx| { AcpThreadView::new( @@ -121,7 +110,7 @@ impl AgentThreadPane { None, workspace, project, - history_store, + thread_store, prompt_store, true, window, diff --git a/crates/agent_ui_v2/src/agents_panel.rs b/crates/agent_ui_v2/src/agents_panel.rs index 254b8d2999dd3f9ce99c07a20273cbb1ca9cb929..32bc9e03ea5320c956a81816f5e44f0600479d4a 100644 --- a/crates/agent_ui_v2/src/agents_panel.rs +++ b/crates/agent_ui_v2/src/agents_panel.rs @@ -1,7 +1,6 @@ -use agent::{HistoryEntry, HistoryEntryId, HistoryStore}; +use agent::{DbThreadMetadata, ThreadStore}; use agent_settings::AgentSettings; use anyhow::Result; -use assistant_text_thread::TextThreadStore; use db::kvp::KEY_VALUE_STORE; use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use fs::Fs; @@ -10,7 +9,7 @@ use gpui::{ WeakEntity, actions, prelude::*, }; use project::Project; -use prompt_store::{PromptBuilder, PromptStore}; +use prompt_store::PromptStore; use serde::{Deserialize, Serialize}; use settings::{Settings as _, update_settings_file}; use std::sync::Arc; @@ -58,7 +57,7 @@ pub struct AgentsPanel { project: Entity, agent_thread_pane: Option>, history: Entity, - history_store: Entity, + thread_store: Entity, prompt_store: Option>, fs: Arc, width: Option, @@ -84,24 +83,12 @@ impl AgentsPanel { }) .await; - let (fs, project, prompt_builder) = workspace.update(cx, |workspace, cx| { + let (fs, project) = workspace.update(cx, |workspace, _| { let fs = workspace.app_state().fs.clone(); let project = workspace.project().clone(); - let prompt_builder = PromptBuilder::load(fs.clone(), false, cx); - (fs, project, prompt_builder) + (fs, project) })?; - let text_thread_store = workspace - .update(cx, |_, cx| { - TextThreadStore::new( - project.clone(), - prompt_builder.clone(), - Default::default(), - cx, - ) - })? - .await?; - let prompt_store = workspace .update(cx, |_, cx| PromptStore::global(cx))? .await @@ -109,15 +96,8 @@ impl AgentsPanel { workspace.update_in(cx, |_, window, cx| { cx.new(|cx| { - let mut panel = Self::new( - workspace.clone(), - fs, - project, - prompt_store, - text_thread_store, - window, - cx, - ); + let mut panel = + Self::new(workspace.clone(), fs, project, prompt_store, window, cx); if let Some(serialized_panel) = serialized_panel { panel.width = serialized_panel.width; if let Some(serialized_pane) = serialized_panel.pane { @@ -135,14 +115,13 @@ impl AgentsPanel { fs: Arc, project: Entity, prompt_store: Option>, - text_thread_store: Entity, window: &mut Window, cx: &mut ui::Context, ) -> Self { let focus_handle = cx.focus_handle(); - let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); - let history = cx.new(|cx| AcpThreadHistory::new(history_store.clone(), window, cx)); + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + let history = cx.new(|cx| AcpThreadHistory::new(thread_store.clone(), window, cx)); let this = cx.weak_entity(); let subscriptions = vec![ @@ -161,7 +140,7 @@ impl AgentsPanel { project, agent_thread_pane: None, history, - history_store, + thread_store, prompt_store, fs, width: None, @@ -181,18 +160,11 @@ impl AgentsPanel { }; let entry = self - .history_store + .thread_store .read(cx) .entries() - .find(|e| match (&e.id(), thread_id) { - ( - HistoryEntryId::AcpThread(session_id), - SerializedHistoryEntryId::AcpThread(id), - ) => session_id.to_string() == *id, - (HistoryEntryId::TextThread(path), SerializedHistoryEntryId::TextThread(id)) => { - path.to_string_lossy() == *id - } - _ => false, + .find(|e| match thread_id { + SerializedHistoryEntryId::AcpThread(id) => e.id.to_string() == *id, }); if let Some(entry) = entry { @@ -247,13 +219,13 @@ impl AgentsPanel { fn open_thread( &mut self, - entry: HistoryEntry, + entry: DbThreadMetadata, expanded: bool, width: Option, window: &mut Window, cx: &mut Context, ) { - let entry_id = entry.id(); + let entry_id = entry.id.clone(); if let Some(existing_pane) = &self.agent_thread_pane { if existing_pane.read(cx).thread_id() == Some(entry_id) { @@ -267,7 +239,7 @@ impl AgentsPanel { let fs = self.fs.clone(); let workspace = self.workspace.clone(); let project = self.project.clone(); - let history_store = self.history_store.clone(); + let thread_store = self.thread_store.clone(); let prompt_store = self.prompt_store.clone(); let agent_thread_pane = cx.new(|cx| { @@ -277,7 +249,7 @@ impl AgentsPanel { fs, workspace.clone(), project, - history_store, + thread_store, prompt_store, window, cx, diff --git a/crates/agent_ui_v2/src/thread_history.rs b/crates/agent_ui_v2/src/thread_history.rs index 0e379a24fc3047e6a686046ea16a94ef25efb52c..f45fa77daa49b40475457b5bca603ae6a006db29 100644 --- a/crates/agent_ui_v2/src/thread_history.rs +++ b/crates/agent_ui_v2/src/thread_history.rs @@ -1,4 +1,4 @@ -use agent::{HistoryEntry, HistoryStore}; +use agent::{DbThreadMetadata, ThreadStore}; use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; use editor::{Editor, EditorEvent}; use fuzzy::StringMatchCandidate; @@ -14,6 +14,16 @@ use ui::{ prelude::*, }; +const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread"); + +fn thread_title(entry: &DbThreadMetadata) -> &SharedString { + if entry.title.is_empty() { + DEFAULT_TITLE + } else { + &entry.title + } +} + actions!( agents, [ @@ -25,7 +35,7 @@ actions!( ); pub struct AcpThreadHistory { - pub(crate) history_store: Entity, + pub(crate) thread_store: Entity, scroll_handle: UniformListScrollHandle, selected_index: usize, hovered_index: Option, @@ -41,17 +51,17 @@ pub struct AcpThreadHistory { enum ListItemType { BucketSeparator(TimeBucket), Entry { - entry: HistoryEntry, + entry: DbThreadMetadata, format: EntryTimeFormat, }, SearchResult { - entry: HistoryEntry, + entry: DbThreadMetadata, positions: Vec, }, } impl ListItemType { - fn history_entry(&self) -> Option<&HistoryEntry> { + fn history_entry(&self) -> Option<&DbThreadMetadata> { match self { ListItemType::Entry { entry, .. } => Some(entry), ListItemType::SearchResult { entry, .. } => Some(entry), @@ -62,14 +72,14 @@ impl ListItemType { #[allow(dead_code)] pub enum ThreadHistoryEvent { - Open(HistoryEntry), + Open(DbThreadMetadata), } impl EventEmitter for AcpThreadHistory {} impl AcpThreadHistory { pub fn new( - history_store: Entity, + thread_store: Entity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -90,14 +100,14 @@ impl AcpThreadHistory { } }); - let history_store_subscription = cx.observe(&history_store, |this, _, cx| { + let thread_store_subscription = cx.observe(&thread_store, |this, _, cx| { this.update_visible_items(true, cx); }); let scroll_handle = UniformListScrollHandle::default(); let mut this = Self { - history_store, + thread_store, scroll_handle, selected_index: 0, hovered_index: None, @@ -109,7 +119,7 @@ impl AcpThreadHistory { .unwrap(), search_query: SharedString::default(), confirming_delete_history: false, - _subscriptions: vec![search_editor_subscription, history_store_subscription], + _subscriptions: vec![search_editor_subscription, thread_store_subscription], _update_task: Task::ready(()), }; this.update_visible_items(false, cx); @@ -118,7 +128,7 @@ impl AcpThreadHistory { fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { let entries = self - .history_store + .thread_store .update(cx, |store, _| store.entries().collect()); let new_list_items = if self.search_query.is_empty() { self.add_list_separators(entries, cx) @@ -135,13 +145,12 @@ impl AcpThreadHistory { 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 { - let history_entry_id = history_entry.id(); new_visible_items .iter() .position(|visible_entry| { visible_entry .history_entry() - .is_some_and(|entry| entry.id() == history_entry_id) + .is_some_and(|entry| entry.id == history_entry.id) }) .unwrap_or(0) } else { @@ -156,18 +165,18 @@ impl AcpThreadHistory { }); } - fn add_list_separators(&self, entries: Vec, cx: &App) -> Task> { + 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_date = entry - .updated_at() - .with_timezone(&Local) - .naive_local() - .date(); + let entry_date = entry.updated_at.with_timezone(&Local).naive_local().date(); let entry_bucket = TimeBucket::from_dates(today, entry_date); if Some(entry_bucket) != bucket { @@ -186,7 +195,7 @@ impl AcpThreadHistory { fn filter_search_results( &self, - entries: Vec, + entries: Vec, cx: &App, ) -> Task> { let query = self.search_query.clone(); @@ -196,7 +205,7 @@ impl AcpThreadHistory { let mut candidates = Vec::with_capacity(entries.len()); for (idx, entry) in entries.iter().enumerate() { - candidates.push(StringMatchCandidate::new(idx, entry.title())); + candidates.push(StringMatchCandidate::new(idx, thread_title(entry))); } const MAX_MATCHES: usize = 100; @@ -227,11 +236,11 @@ impl AcpThreadHistory { self.visible_items.is_empty() && !self.search_query.is_empty() } - fn selected_history_entry(&self) -> Option<&HistoryEntry> { + fn selected_history_entry(&self) -> Option<&DbThreadMetadata> { self.get_history_entry(self.selected_index) } - fn get_history_entry(&self, visible_items_ix: usize) -> Option<&HistoryEntry> { + fn get_history_entry(&self, visible_items_ix: usize) -> Option<&DbThreadMetadata> { self.visible_items.get(visible_items_ix)?.history_entry() } @@ -331,19 +340,14 @@ impl AcpThreadHistory { return; }; - let task = match entry { - HistoryEntry::AcpThread(thread) => self - .history_store - .update(cx, |this, cx| this.delete_thread(thread.id.clone(), cx)), - HistoryEntry::TextThread(text_thread) => self.history_store.update(cx, |this, cx| { - this.delete_text_thread(text_thread.path.clone(), cx) - }), - }; + let task = self + .thread_store + .update(cx, |store, cx| store.delete_thread(entry.id.clone(), cx)); task.detach_and_log_err(cx); } fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { - self.history_store.update(cx, |store, cx| { + self.thread_store.update(cx, |store, cx| { store.delete_threads(cx).detach_and_log_err(cx) }); self.confirming_delete_history = false; @@ -402,7 +406,7 @@ impl AcpThreadHistory { fn render_history_entry( &self, - entry: &HistoryEntry, + entry: &DbThreadMetadata, format: EntryTimeFormat, ix: usize, highlight_positions: Vec, @@ -410,11 +414,11 @@ impl AcpThreadHistory { ) -> AnyElement { let selected = ix == self.selected_index; let hovered = Some(ix) == self.hovered_index; - let timestamp = entry.updated_at().timestamp(); + let timestamp = entry.updated_at.timestamp(); let display_text = match format { EntryTimeFormat::DateAndTime => { - let entry_time = entry.updated_at(); + let entry_time = entry.updated_at; let now = Utc::now(); let duration = now.signed_duration_since(entry_time); let days = duration.num_days(); @@ -424,7 +428,7 @@ impl AcpThreadHistory { EntryTimeFormat::TimeOnly => format.format_timestamp(timestamp, self.local_timezone), }; - let title = entry.title().clone(); + let title = thread_title(entry).clone(); let full_date = EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone); @@ -442,7 +446,7 @@ impl AcpThreadHistory { .gap_2() .justify_between() .child( - HighlightedLabel::new(entry.title(), highlight_positions) + HighlightedLabel::new(thread_title(entry), highlight_positions) .size(LabelSize::Small) .truncate(), ) @@ -495,7 +499,7 @@ impl Focusable for AcpThreadHistory { impl Render for AcpThreadHistory { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let has_no_history = self.history_store.read(cx).is_empty(cx); + let has_no_history = self.thread_store.read(cx).is_empty(); v_flex() .key_context("ThreadHistory") diff --git a/crates/assistant_text_thread/src/text_thread_store.rs b/crates/assistant_text_thread/src/text_thread_store.rs index 2e859c42ef2f9ed86404b80dabf56be71acacfa4..bee5f1b836d5eb741514dfcf9f59afeaaa446554 100644 --- a/crates/assistant_text_thread/src/text_thread_store.rs +++ b/crates/assistant_text_thread/src/text_thread_store.rs @@ -12,6 +12,7 @@ use fs::{Fs, RemoveOptions}; use futures::StreamExt; use fuzzy::StringMatchCandidate; use gpui::{App, AppContext as _, AsyncApp, Context, Entity, Task, WeakEntity}; +use itertools::Itertools; use language::LanguageRegistry; use paths::text_threads_dir; use project::{ @@ -363,8 +364,15 @@ impl TextThreadStore { } } - pub fn unordered_text_threads(&self) -> impl Iterator { - self.text_threads_metadata.iter() + /// Returns saved threads ordered by `mtime` descending (newest first). + pub fn ordered_text_threads(&self) -> impl Iterator { + self.text_threads_metadata + .iter() + .sorted_by(|a, b| b.mtime.cmp(&a.mtime)) + } + + pub fn has_saved_text_threads(&self) -> bool { + !self.text_threads_metadata.is_empty() } pub fn host_text_threads(&self) -> impl Iterator { @@ -514,6 +522,36 @@ impl TextThreadStore { }) } + pub fn delete_all_local(&mut self, cx: &mut Context) -> Task> { + let fs = self.fs.clone(); + let paths = self + .text_threads_metadata + .iter() + .map(|metadata| metadata.path.clone()) + .collect::>(); + + cx.spawn(async move |this, cx| { + for path in paths { + fs.remove_file( + &path, + RemoveOptions { + recursive: false, + ignore_if_not_exists: true, + }, + ) + .await?; + } + + this.update(cx, |this, cx| { + this.text_threads.clear(); + this.text_threads_metadata.clear(); + cx.notify(); + })?; + + Ok(()) + }) + } + fn loaded_text_thread_for_path(&self, path: &Path, cx: &App) -> Option> { self.text_threads.iter().find_map(|text_thread| { let text_thread = text_thread.upgrade()?; @@ -930,3 +968,129 @@ impl TextThreadStore { .detach(); } } + +#[cfg(test)] +mod tests { + use super::*; + use fs::FakeFs; + use language_model::LanguageModelRegistry; + use project::Project; + use serde_json::json; + use settings::SettingsStore; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + + fn init_test(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + prompt_store::init(cx); + LanguageModelRegistry::test(cx); + cx.set_global(settings_store); + }); + } + + #[gpui::test] + async fn ordered_text_threads_sort_by_mtime(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree("/root", json!({})).await; + + let project = Project::test(fs, [Path::new("/root")], cx).await; + let store = cx.new(|cx| TextThreadStore::fake(project, cx)); + + let now = chrono::Local::now(); + let older = SavedTextThreadMetadata { + title: "older".into(), + path: Arc::from(PathBuf::from("/root/older.zed.json")), + mtime: now - chrono::TimeDelta::days(1), + }; + let middle = SavedTextThreadMetadata { + title: "middle".into(), + path: Arc::from(PathBuf::from("/root/middle.zed.json")), + mtime: now - chrono::TimeDelta::hours(1), + }; + let newer = SavedTextThreadMetadata { + title: "newer".into(), + path: Arc::from(PathBuf::from("/root/newer.zed.json")), + mtime: now, + }; + + store.update(cx, |store, _| { + store.text_threads_metadata = vec![middle, older, newer]; + }); + + let ordered = store.read_with(cx, |store, _| { + store + .ordered_text_threads() + .map(|entry| entry.title.to_string()) + .collect::>() + }); + + assert_eq!(ordered, vec!["newer", "middle", "older"]); + } + + #[gpui::test] + async fn has_saved_text_threads_reflects_metadata(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree("/root", json!({})).await; + + let project = Project::test(fs, [Path::new("/root")], cx).await; + let store = cx.new(|cx| TextThreadStore::fake(project, cx)); + + assert!(!store.read_with(cx, |store, _| store.has_saved_text_threads())); + + store.update(cx, |store, _| { + store.text_threads_metadata = vec![SavedTextThreadMetadata { + title: "thread".into(), + path: Arc::from(PathBuf::from("/root/thread.zed.json")), + mtime: chrono::Local::now(), + }]; + }); + + assert!(store.read_with(cx, |store, _| store.has_saved_text_threads())); + } + + #[gpui::test] + async fn delete_all_local_clears_metadata_and_files(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree("/root", json!({})).await; + + let thread_a = PathBuf::from("/root/thread-a.zed.json"); + let thread_b = PathBuf::from("/root/thread-b.zed.json"); + fs.touch_path(&thread_a).await; + fs.touch_path(&thread_b).await; + + let project = Project::test(fs.clone(), [Path::new("/root")], cx).await; + let store = cx.new(|cx| TextThreadStore::fake(project, cx)); + + let now = chrono::Local::now(); + store.update(cx, |store, cx| { + store.create(cx); + store.text_threads_metadata = vec![ + SavedTextThreadMetadata { + title: "thread-a".into(), + path: Arc::from(thread_a.clone()), + mtime: now, + }, + SavedTextThreadMetadata { + title: "thread-b".into(), + path: Arc::from(thread_b.clone()), + mtime: now - chrono::TimeDelta::seconds(1), + }, + ]; + }); + + let task = store.update(cx, |store, cx| store.delete_all_local(cx)); + task.await.unwrap(); + + assert!(!store.read_with(cx, |store, _| store.has_saved_text_threads())); + assert_eq!(store.read_with(cx, |store, _| store.text_threads.len()), 0); + assert!(fs.metadata(&thread_a).await.unwrap().is_none()); + assert!(fs.metadata(&thread_b).await.unwrap().is_none()); + } +} diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 16f3987ff483816872491803fe479fec7676183f..48c59722bd0a3538c014c63dc95d1e2eaa8f5921 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -4,7 +4,7 @@ mod reliability; mod zed; -use agent::{HistoryStore, SharedThread}; +use agent::{SharedThread, ThreadStore}; use agent_client_protocol; use agent_ui::AgentPanel; use anyhow::{Context as _, Error, Result}; @@ -845,17 +845,16 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut let workspace = workspace::get_any_active_workspace(app_state.clone(), cx.clone()).await?; - let (client, history_store) = + let (client, thread_store) = workspace.update(cx, |workspace, _window, cx| { let client = workspace.project().read(cx).client(); - let history_store: Option> = workspace + let thread_store: Option> = workspace .panel::(cx) .map(|panel| panel.read(cx).thread_store().clone()); - (client, history_store) + (client, thread_store) })?; - let Some(history_store): Option> = history_store - else { + let Some(thread_store): Option> = thread_store else { anyhow::bail!("Agent panel not available"); }; @@ -870,9 +869,11 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut let db_thread = shared_thread.to_db_thread(); let session_id = agent_client_protocol::SessionId::new(session_id); - history_store + let save_session_id = session_id.clone(); + + thread_store .update(&mut cx.clone(), |store, cx| { - store.save_thread(session_id.clone(), db_thread, cx) + store.save_thread(save_session_id.clone(), db_thread, cx) }) .await?; diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index 0197bfb3ecc7b4ea245c6ce6963c3b592558a90f..6ea1cb0ad83acf92e426e53d06a9ff4d41eaae0a 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -43,9 +43,7 @@ The checkpoint button appears even if you interrupt the thread midway through an ### Navigating History {#navigating-history} -To quickly navigate through recently opened threads, use the {#kb agent::ToggleNavigationMenu} binding, when focused on the panel's editor, or click the menu icon button at the top right of the panel to open the dropdown that shows you the six most recent threads. - -The items in this menu function similarly to tabs, and closing them doesn’t delete the thread; instead, it simply removes them from the recent list. +To quickly navigate through recently updated threads, use the {#kb agent::ToggleNavigationMenu} binding, when focused on the panel's editor, or click the menu icon button at the top right of the panel to open the dropdown that shows you the six most recently updated threads. To view all historical conversations, reach for the `View All` option from within the same menu or via the {#kb agent::OpenHistory} binding.