Detailed changes
@@ -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",
@@ -263,12 +263,6 @@
"ctrl-alt-z": "agent::RejectOnce",
},
},
- {
- "context": "AgentPanel > NavigationMenu",
- "bindings": {
- "shift-backspace": "agent::DeleteRecentlyOpenThread",
- },
- },
{
"context": "AgentPanel > Markdown",
"bindings": {
@@ -304,12 +304,6 @@
"cmd-alt-z": "agent::RejectOnce",
},
},
- {
- "context": "AgentPanel > NavigationMenu",
- "bindings": {
- "shift-backspace": "agent::DeleteRecentlyOpenThread",
- },
- },
{
"context": "AgentPanel > Markdown",
"use_key_equivalents": true,
@@ -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,
@@ -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"] }
@@ -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<acp::SessionId, Session>,
- history: Entity<HistoryStore>,
+ thread_store: Entity<ThreadStore>,
/// Shared project context for all threads
project_context: Entity<ProjectContext>,
project_context_needs_refresh: watch::Sender<()>,
@@ -243,7 +243,7 @@ pub struct NativeAgent {
impl NativeAgent {
pub async fn new(
project: Entity<Project>,
- history: Entity<HistoryStore>,
+ thread_store: Entity<ThreadStore>,
templates: Arc<Templates>,
prompt_store: Option<Entity<PromptStore>>,
fs: Arc<dyn Fs>,
@@ -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<HistoryStore>,
+ fn thread_entries(
+ thread_store: &Entity<ThreadStore>,
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::<Vec<_>>()
})
}
@@ -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::<str>::from(value))
+ }
+
+ fn make_thread(title: &str, updated_at: DateTime<Utc>) -> 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()
+ );
+ }
}
@@ -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<HistoryStore>,
- project: Entity<Project>,
- cx: &mut App,
-) -> Task<Result<Entity<crate::Thread>>> {
- 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::<crate::NativeAgentConnection>().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<Utc> {
- 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<Path>),
-}
-
-impl Into<ElementId> 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<DbThreadMetadata>,
- entries: Vec<HistoryEntry>,
- text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
- recently_opened_entries: VecDeque<HistoryEntryId>,
- _subscriptions: Vec<gpui::Subscription>,
- _save_recently_opened_entries_task: Task<()>,
-}
-
-impl HistoryStore {
- pub fn new(
- text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
- cx: &mut Context<Self>,
- ) -> 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<Self>,
- ) -> Task<Result<Option<DbThread>>> {
- 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<Self>,
- ) -> Task<Result<()>> {
- 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<Self>,
- ) -> Task<Result<()>> {
- 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<Self>) -> Task<Result<()>> {
- 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<Path>,
- cx: &mut Context<Self>,
- ) -> Task<Result<()>> {
- self.text_thread_store
- .update(cx, |store, cx| store.delete_local(path, cx))
- }
-
- pub fn load_text_thread(
- &self,
- path: Arc<Path>,
- cx: &mut Context<Self>,
- ) -> Task<Result<Entity<TextThread>>> {
- self.text_thread_store
- .update(cx, |store, cx| store.open_local(path, cx))
- }
-
- pub fn reload(&self, cx: &mut Context<Self>) {
- 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<Self>) {
- #[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<HistoryEntry> {
- #[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<Self>) {
- 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::<Vec<_>>();
-
- 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<Result<VecDeque<HistoryEntryId>>> {
- 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::<Vec<SerializedRecentOpen>>(&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>) {
- 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>) {
- 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<Path>,
- cx: &mut Context<Self>,
- ) {
- 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>) {
- self.recently_opened_entries
- .retain(|old_entry| old_entry != entry);
- self.save_recently_opened_entries(cx);
- }
-
- pub fn entries(&self) -> impl Iterator<Item = HistoryEntry> {
- self.entries.iter().cloned()
- }
-}
@@ -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<dyn Fs>,
- history: Entity<HistoryStore>,
+ thread_store: Entity<ThreadStore>,
}
impl NativeAgentServer {
- pub fn new(fs: Arc<dyn Fs>, history: Entity<HistoryStore>) -> Self {
- Self { fs, history }
+ pub fn new(fs: Arc<dyn Fs>, thread_store: Entity<ThreadStore>) -> 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"
);
@@ -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(),
@@ -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<ThreadStore>,
+ project: Entity<Project>,
+ cx: &mut App,
+) -> Task<Result<Entity<crate::Thread>>> {
+ 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::<crate::NativeAgentConnection>().unwrap();
+ cx.update(|cx| agent.load_thread(session_id, cx)).await
+ })
+}
+
+pub struct ThreadStore {
+ threads: Vec<DbThreadMetadata>,
+}
+
+impl ThreadStore {
+ pub fn new(cx: &mut Context<Self>) -> 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<Self>,
+ ) -> Task<Result<Option<DbThread>>> {
+ 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<Self>,
+ ) -> Task<Result<()>> {
+ 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<Self>,
+ ) -> Task<Result<()>> {
+ 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<Self>) -> Task<Result<()>> {
+ 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<Self>) {
+ 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<Item = DbThreadMetadata> + '_ {
+ 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::<str>::from(value))
+ }
+
+ fn make_thread(title: &str, updated_at: DateTime<Utc>) -> 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);
+ }
+}
@@ -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 {
@@ -19,17 +19,11 @@ use util::path;
pub async fn test_basic<T, F>(server: F, cx: &mut TestAppContext)
where
T: AgentServer + 'static,
- F: AsyncFn(&Arc<dyn fs::Fs>, &Entity<Project>, &mut TestAppContext) -> T,
+ F: AsyncFn(&Arc<dyn fs::Fs>, &mut TestAppContext) -> T,
{
let fs = init_test(cx).await as Arc<dyn fs::Fs>;
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<T, F>(server: F, cx: &mut TestAppContext)
where
T: AgentServer + 'static,
- F: AsyncFn(&Arc<dyn fs::Fs>, &Entity<Project>, &mut TestAppContext) -> T,
+ F: AsyncFn(&Arc<dyn fs::Fs>, &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<T, F>(server: F, cx: &mut TestAppContext)
where
T: AgentServer + 'static,
- F: AsyncFn(&Arc<dyn fs::Fs>, &Entity<Project>, &mut TestAppContext) -> T,
+ F: AsyncFn(&Arc<dyn fs::Fs>, &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<T, F>(
cx: &mut TestAppContext,
) where
T: AgentServer + 'static,
- F: AsyncFn(&Arc<dyn fs::Fs>, &Entity<Project>, &mut TestAppContext) -> T,
+ F: AsyncFn(&Arc<dyn fs::Fs>, &mut TestAppContext) -> T,
{
let fs = init_test(cx).await as Arc<dyn fs::Fs>;
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<T, F>(
pub async fn test_cancel<T, F>(server: F, cx: &mut TestAppContext)
where
T: AgentServer + 'static,
- F: AsyncFn(&Arc<dyn fs::Fs>, &Entity<Project>, &mut TestAppContext) -> T,
+ F: AsyncFn(&Arc<dyn fs::Fs>, &mut TestAppContext) -> T,
{
let fs = init_test(cx).await as Arc<dyn fs::Fs>;
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<T, F>(server: F, cx: &mut TestAppContext)
where
T: AgentServer + 'static,
- F: AsyncFn(&Arc<dyn fs::Fs>, &Entity<Project>, &mut TestAppContext) -> T,
+ F: AsyncFn(&Arc<dyn fs::Fs>, &mut TestAppContext) -> T,
{
let fs = init_test(cx).await as Arc<dyn fs::Fs>;
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))
@@ -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"))
@@ -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<Workspace>,
project: WeakEntity<Project>,
- history_store: Entity<HistoryStore>,
+ history_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
entries: Vec<Entry>,
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
@@ -35,7 +35,7 @@ impl EntryViewState {
pub fn new(
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
- history_store: Entity<HistoryStore>,
+ history_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
@@ -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(
@@ -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<Workspace>,
project: WeakEntity<Project>,
- history_store: Entity<HistoryStore>,
+ history_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
@@ -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
@@ -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<HistoryStore>,
+ pub(crate) thread_store: Entity<ThreadStore>,
scroll_handle: UniformListScrollHandle,
selected_index: usize,
hovered_index: Option<usize>,
@@ -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<usize>,
},
}
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<ThreadHistoryEvent> for AcpThreadHistory {}
impl AcpThreadHistory {
pub(crate) fn new(
- history_store: Entity<agent::HistoryStore>,
+ thread_store: Entity<ThreadStore>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Self>) {
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<HistoryEntry>, cx: &App) -> Task<Vec<ListItemType>> {
+ fn add_list_separators(
+ &self,
+ entries: Vec<DbThreadMetadata>,
+ cx: &App,
+ ) -> Task<Vec<ListItemType>> {
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<HistoryEntry>,
+ entries: Vec<DbThreadMetadata>,
cx: &App,
) -> Task<Vec<ListItemType>> {
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>) {
- 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<usize>,
@@ -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<Self>) -> 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<AcpThreadView>,
selected: bool,
hovered: bool,
@@ -627,7 +631,7 @@ pub struct AcpHistoryEntryElement {
}
impl AcpHistoryEntryElement {
- pub fn new(entry: HistoryEntry, thread_view: WeakEntity<AcpThreadView>) -> Self {
+ pub fn new(entry: DbThreadMetadata, thread_view: WeakEntity<AcpThreadView>) -> 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::<AgentPanel>(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::<AgentPanel>(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::<AgentPanel>(cx) {
+ panel.update(cx, |panel, cx| {
+ panel.load_agent_thread(entry.clone(), window, cx);
+ });
}
}
}
@@ -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<Project>,
thread_state: ThreadState,
login: Option<task::SpawnInTerminal>,
- history_store: Entity<HistoryStore>,
+ thread_store: Entity<ThreadStore>,
hovered_recent_history_item: Option<usize>,
entry_view_state: Entity<EntryViewState>,
message_editor: Entity<MessageEditor>,
@@ -394,7 +392,7 @@ impl AcpThreadView {
summarize_thread: Option<DbThreadMetadata>,
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
- history_store: Entity<HistoryStore>,
+ thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
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::<agent::NativeAgentServer>()
.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<Self>) {
- 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<Self>) {
+ 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,
@@ -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<AcpThreadView>,
@@ -219,7 +228,9 @@ enum ActiveView {
buffer_search_bar: Entity<BufferSearchBar>,
_subscriptions: Vec<gpui::Subscription>,
},
- History,
+ History {
+ kind: HistoryKind,
+ },
Configuration,
}
@@ -280,7 +291,7 @@ impl From<ExternalAgent> 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<dyn Fs>,
prompt_store: Option<Entity<PromptStore>>,
- history_store: Entity<agent::HistoryStore>,
+ thread_store: Entity<ThreadStore>,
project: Entity<Project>,
workspace: WeakEntity<Workspace>,
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<TextThreadEditor>,
- acp_history_store: Entity<agent::HistoryStore>,
language_registry: Arc<LanguageRegistry>,
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<dyn Fs>,
language_registry: Arc<LanguageRegistry>,
acp_history: Entity<AcpThreadHistory>,
- history_store: Entity<agent::HistoryStore>,
+ text_thread_history: Entity<TextThreadHistory>,
+ thread_store: Entity<ThreadStore>,
text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
context_server_registry: Entity<ContextServerRegistry>,
@@ -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<HistoryStore> {
- &self.history_store
+ pub fn thread_store(&self) -> &Entity<ThreadStore> {
+ &self.thread_store
}
pub fn open_thread(
@@ -765,7 +771,9 @@ impl AgentPanel {
fn active_thread_view(&self) -> Option<&Entity<AcpThreadView>> {
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<Self>,
) {
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<HistoryKind> {
+ 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<Self>) {
- 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<Self>,
) -> Task<Result<()>> {
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<Self>) {
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<Self>,
) {
+ 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<Self>,
) {
- 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<Self>,
+ kind: HistoryKind,
cx: &mut Context<ContextMenu>,
) -> 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::<Vec<_>>();
- 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::<Vec<_>>();
+
+ 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,
@@ -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<dyn fs::Fs>,
- history: Entity<agent::HistoryStore>,
+ thread_store: Entity<agent::ThreadStore>,
) -> Rc<dyn agent_servers::AgentServer> {
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())),
}
}
@@ -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<T: PromptCompletionProviderDelegate> {
source: Arc<T>,
editor: WeakEntity<Editor>,
mention_set: Entity<MentionSet>,
- history_store: Entity<HistoryStore>,
+ thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
workspace: WeakEntity<Workspace>,
}
@@ -196,7 +204,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
source: T,
editor: WeakEntity<Editor>,
mention_set: Entity<MentionSet>,
- history_store: Entity<HistoryStore>,
+ thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
workspace: WeakEntity<Workspace>,
) -> Self {
@@ -205,7 +213,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
editor,
mention_set,
workspace,
- history_store,
+ thread_store,
prompt_store,
}
}
@@ -246,7 +254,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
}
fn completion_for_thread(
- thread_entry: HistoryEntry,
+ thread_entry: DbThreadMetadata,
source_range: Range<Anchor>,
recent: bool,
source: Arc<T>,
@@ -255,7 +263,11 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
workspace: Entity<Workspace>,
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<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
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<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
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<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
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<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
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::<Vec<_>>();
@@ -1529,9 +1546,9 @@ pub(crate) fn search_symbols(
pub(crate) fn search_threads(
query: String,
cancellation_flag: Arc<AtomicBool>,
- thread_store: &Entity<HistoryStore>,
+ thread_store: &Entity<ThreadStore>,
cx: &mut App,
-) -> Task<Vec<HistoryEntry>> {
+) -> Task<Vec<DbThreadMetadata>> {
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::<Vec<_>>();
let matches = fuzzy::match_strings(
&candidates,
@@ -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<Editor>,
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
- thread_store: Entity<HistoryStore>,
+ thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
initial_prompt: Option<String>,
window: &mut Window,
@@ -605,7 +605,7 @@ impl InlineAssistant {
editor: &Entity<Editor>,
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
- thread_store: Entity<HistoryStore>,
+ thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
initial_prompt: Option<String>,
window: &mut Window,
@@ -648,7 +648,7 @@ impl InlineAssistant {
initial_transaction_id: Option<TransactionId>,
focus: bool,
workspace: Entity<Workspace>,
- thread_store: Entity<HistoryStore>,
+ thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
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,
@@ -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<T> {
pub editor: Entity<Editor>,
mode: PromptEditorMode,
mention_set: Entity<MentionSet>,
- history_store: Entity<HistoryStore>,
+ thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
workspace: WeakEntity<Workspace>,
model_selector: Entity<AgentModelSelector>,
@@ -331,7 +331,7 @@ impl<T: 'static> PromptEditor<T> {
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<BufferCodegen> {
codegen: Entity<BufferCodegen>,
session_id: Uuid,
fs: Arc<dyn Fs>,
- history_store: Entity<HistoryStore>,
+ thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
project: WeakEntity<Project>,
workspace: WeakEntity<Workspace>,
@@ -1250,14 +1250,14 @@ impl PromptEditor<BufferCodegen> {
});
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<BufferCodegen> = 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<TerminalCodegen> {
codegen: Entity<TerminalCodegen>,
session_id: Uuid,
fs: Arc<dyn Fs>,
- history_store: Entity<HistoryStore>,
+ thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
project: WeakEntity<Project>,
workspace: WeakEntity<Workspace>,
@@ -1403,14 +1403,14 @@ impl PromptEditor<TerminalCodegen> {
});
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| {
@@ -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<Project>,
- history_store: Entity<HistoryStore>,
+ thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
mentions: HashMap<CreaseId, (MentionUri, MentionTask)>,
}
@@ -68,12 +68,12 @@ pub struct MentionSet {
impl MentionSet {
pub fn new(
project: WeakEntity<Project>,
- history_store: Entity<HistoryStore>,
+ thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
) -> 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<Self>,
- ) -> Task<Result<Mention>> {
- 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(
@@ -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<TerminalView>,
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
- thread_store: Entity<HistoryStore>,
+ thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
initial_prompt: Option<String>,
window: &mut Window,
@@ -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<TextThreadStore>,
+ scroll_handle: UniformListScrollHandle,
+ selected_index: usize,
+ hovered_index: Option<usize>,
+ search_editor: Entity<Editor>,
+ search_query: SharedString,
+ visible_items: Vec<ListItemType>,
+ local_timezone: UtcOffset,
+ confirming_delete_history: bool,
+ _update_task: Task<()>,
+ _subscriptions: Vec<gpui::Subscription>,
+}
+
+enum ListItemType {
+ BucketSeparator(TimeBucket),
+ Entry {
+ entry: SavedTextThreadMetadata,
+ format: EntryTimeFormat,
+ },
+ SearchResult {
+ entry: SavedTextThreadMetadata,
+ positions: Vec<usize>,
+ },
+}
+
+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<TextThreadHistoryEvent> for TextThreadHistory {}
+
+impl TextThreadHistory {
+ pub(crate) fn new(
+ text_thread_store: Entity<TextThreadStore>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> 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<Self>) {
+ let entries = self.text_thread_store.update(cx, |store, _| {
+ store.ordered_text_threads().cloned().collect::<Vec<_>>()
+ });
+
+ 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<SavedTextThreadMetadata>,
+ cx: &App,
+ ) -> Task<Vec<ListItemType>> {
+ 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<SavedTextThreadMetadata>,
+ cx: &App,
+ ) -> Task<Vec<ListItemType>> {
+ 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<Self>) {
+ 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<Self>) {
+ 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<Self>,
+ ) {
+ 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>,
+ ) {
+ self.set_selected_index(0, Bias::Right, cx);
+ }
+
+ fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+ 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>) {
+ self.confirm_entry(self.selected_index, cx);
+ }
+
+ fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
+ 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>,
+ ) {
+ self.remove_thread(self.selected_index, cx)
+ }
+
+ fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
+ 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>) {
+ 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>) {
+ self.confirming_delete_history = true;
+ cx.notify();
+ }
+
+ fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+ self.confirming_delete_history = false;
+ cx.notify();
+ }
+
+ fn render_list_items(
+ &mut self,
+ range: Range<usize>,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Vec<AnyElement> {
+ 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<Self>) -> 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<usize>,
+ cx: &Context<Self>,
+ ) -> 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::<IconButton>(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<Self>) -> 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<usize>, 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<TimeBucket> 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);
+ }
+}
@@ -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
@@ -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<HistoryEntryId> 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<acp::SessionId> for SerializedHistoryEntryId {
+ fn from(id: acp::SessionId) -> Self {
+ SerializedHistoryEntryId::AcpThread(id.0.to_string())
}
}
@@ -58,7 +51,7 @@ impl EventEmitter<ClosePane> for AgentThreadPane {}
struct ActiveThreadView {
view: Entity<AcpThreadView>,
- thread_id: HistoryEntryId,
+ thread_id: acp::SessionId,
_notify: Subscription,
}
@@ -82,7 +75,7 @@ impl AgentThreadPane {
}
}
- pub fn thread_id(&self) -> Option<HistoryEntryId> {
+ pub fn thread_id(&self) -> Option<acp::SessionId> {
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<dyn Fs>,
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
- history_store: Entity<HistoryStore>,
+ thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
- 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<dyn AgentServer> = Rc::new(NativeAgentServer::new(fs, history_store.clone()));
+ let agent: Rc<dyn AgentServer> = 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,
@@ -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<Project>,
agent_thread_pane: Option<Entity<AgentThreadPane>>,
history: Entity<AcpThreadHistory>,
- history_store: Entity<HistoryStore>,
+ thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
fs: Arc<dyn Fs>,
width: Option<Pixels>,
@@ -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<dyn Fs>,
project: Entity<Project>,
prompt_store: Option<Entity<PromptStore>>,
- text_thread_store: Entity<TextThreadStore>,
window: &mut Window,
cx: &mut ui::Context<Self>,
) -> 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<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) {
- 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,
@@ -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<HistoryStore>,
+ pub(crate) thread_store: Entity<ThreadStore>,
scroll_handle: UniformListScrollHandle,
selected_index: usize,
hovered_index: Option<usize>,
@@ -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<usize>,
},
}
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<ThreadHistoryEvent> for AcpThreadHistory {}
impl AcpThreadHistory {
pub fn new(
- history_store: Entity<agent::HistoryStore>,
+ thread_store: Entity<ThreadStore>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Self>) {
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<HistoryEntry>, cx: &App) -> Task<Vec<ListItemType>> {
+ fn add_list_separators(
+ &self,
+ entries: Vec<DbThreadMetadata>,
+ cx: &App,
+ ) -> Task<Vec<ListItemType>> {
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<HistoryEntry>,
+ entries: Vec<DbThreadMetadata>,
cx: &App,
) -> Task<Vec<ListItemType>> {
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>) {
- 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<usize>,
@@ -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<Self>) -> 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")
@@ -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<Item = &SavedTextThreadMetadata> {
- self.text_threads_metadata.iter()
+ /// Returns saved threads ordered by `mtime` descending (newest first).
+ pub fn ordered_text_threads(&self) -> impl Iterator<Item = &SavedTextThreadMetadata> {
+ 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<Item = &RemoteTextThreadMetadata> {
@@ -514,6 +522,36 @@ impl TextThreadStore {
})
}
+ pub fn delete_all_local(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
+ let fs = self.fs.clone();
+ let paths = self
+ .text_threads_metadata
+ .iter()
+ .map(|metadata| metadata.path.clone())
+ .collect::<Vec<_>>();
+
+ 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<Entity<TextThread>> {
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::<Vec<_>>()
+ });
+
+ 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());
+ }
+}
@@ -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<AppState>, 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<gpui::Entity<HistoryStore>> = workspace
+ let thread_store: Option<gpui::Entity<ThreadStore>> = workspace
.panel::<AgentPanel>(cx)
.map(|panel| panel.read(cx).thread_store().clone());
- (client, history_store)
+ (client, thread_store)
})?;
- let Some(history_store): Option<gpui::Entity<HistoryStore>> = history_store
- else {
+ let Some(thread_store): Option<gpui::Entity<ThreadStore>> = thread_store else {
anyhow::bail!("Agent panel not available");
};
@@ -870,9 +869,11 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, 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?;
@@ -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.