Previously, each Agent panel created its own `ThreadStore` instance, so thread history updates wouldn't sync between open windows. Users had to close and reopen windows to see threads created in other windows. Now `ThreadStore` is initialized once as a global and shared across all windows. When any window saves a thread, ThreadStore::reload() calls cx.notify(), which notifies all NativeAgentSessionList observers, causing all windows' history views to refresh. Note: This only works for the native Zed agent. It also is only improving the history view (thread titles)—this PR does not sync edits between multiple windows when the same thread is open in each. Release Notes: - Improved Zed agent thread history to update across all open windows
302 lines
9.7 KiB
Rust
302 lines
9.7 KiB
Rust
use crate::{DbThread, DbThreadMetadata, ThreadsDatabase};
|
|
use agent_client_protocol as acp;
|
|
use anyhow::{Result, anyhow};
|
|
use gpui::{App, Context, Entity, Global, Task, prelude::*};
|
|
use project::Project;
|
|
use std::rc::Rc;
|
|
|
|
struct GlobalThreadStore(Entity<ThreadStore>);
|
|
|
|
impl Global for GlobalThreadStore {}
|
|
|
|
// 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 init_global(cx: &mut App) {
|
|
let thread_store = cx.new(|cx| Self::new(cx));
|
|
cx.set_global(GlobalThreadStore(thread_store));
|
|
}
|
|
|
|
pub fn global(cx: &App) -> Entity<Self> {
|
|
cx.global::<GlobalThreadStore>().0.clone()
|
|
}
|
|
|
|
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,
|
|
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);
|
|
}
|
|
}
|