1use std::{path::Path, sync::Arc};
2
3use agent::{ThreadStore, ZED_AGENT_ID};
4use agent_client_protocol as acp;
5use anyhow::Context as _;
6use chrono::{DateTime, Utc};
7use collections::{HashMap, HashSet};
8use db::{
9 sqlez::{
10 bindable::Column, domain::Domain, statement::Statement,
11 thread_safe_connection::ThreadSafeConnection,
12 },
13 sqlez_macros::sql,
14};
15use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
16use futures::{FutureExt as _, future::Shared};
17use gpui::{AppContext as _, Entity, Global, Subscription, Task};
18use project::AgentId;
19use ui::{App, Context, SharedString};
20use util::ResultExt as _;
21use workspace::PathList;
22
23use crate::DEFAULT_THREAD_TITLE;
24
25pub fn init(cx: &mut App) {
26 ThreadMetadataStore::init_global(cx);
27
28 if cx.has_flag::<AgentV2FeatureFlag>() {
29 migrate_thread_metadata(cx);
30 }
31 cx.observe_flag::<AgentV2FeatureFlag, _>(|has_flag, cx| {
32 if has_flag {
33 migrate_thread_metadata(cx);
34 }
35 })
36 .detach();
37}
38
39/// Migrate existing thread metadata from native agent thread store to the new metadata storage.
40/// We skip migrating threads that do not have a project.
41///
42/// TODO: Remove this after N weeks of shipping the sidebar
43fn migrate_thread_metadata(cx: &mut App) {
44 let store = ThreadMetadataStore::global(cx);
45 let db = store.read(cx).db.clone();
46
47 cx.spawn(async move |cx| {
48 let existing_entries = db.list_ids()?.into_iter().collect::<HashSet<_>>();
49
50 let to_migrate = store.read_with(cx, |_store, cx| {
51 ThreadStore::global(cx)
52 .read(cx)
53 .entries()
54 .filter_map(|entry| {
55 if existing_entries.contains(&entry.id.0) || entry.folder_paths.is_empty() {
56 return None;
57 }
58
59 Some(ThreadMetadata {
60 session_id: entry.id,
61 agent_id: ZED_AGENT_ID.clone(),
62 title: entry.title,
63 updated_at: entry.updated_at,
64 created_at: entry.created_at,
65 folder_paths: entry.folder_paths,
66 })
67 })
68 .collect::<Vec<_>>()
69 });
70
71 if to_migrate.is_empty() {
72 return anyhow::Ok(());
73 }
74
75 log::info!("Migrating {} thread store entries", to_migrate.len());
76
77 // Manually save each entry to the database and call reload, otherwise
78 // we'll end up triggering lots of reloads after each save
79 for entry in to_migrate {
80 db.save(entry, Some(true)).await?;
81 }
82
83 log::info!("Finished migrating thread store entries");
84
85 let _ = store.update(cx, |store, cx| store.reload(cx));
86 anyhow::Ok(())
87 })
88 .detach_and_log_err(cx);
89}
90
91struct GlobalThreadMetadataStore(Entity<ThreadMetadataStore>);
92impl Global for GlobalThreadMetadataStore {}
93
94/// Lightweight metadata for any thread (native or ACP), enough to populate
95/// the sidebar list and route to the correct load path when clicked.
96#[derive(Debug, Clone, PartialEq)]
97pub struct ThreadMetadata {
98 pub session_id: acp::SessionId,
99 pub agent_id: AgentId,
100 pub title: SharedString,
101 pub updated_at: DateTime<Utc>,
102 pub created_at: Option<DateTime<Utc>>,
103 pub folder_paths: PathList,
104}
105
106impl From<DbThreadMetadata> for ThreadMetadata {
107 fn from(row: DbThreadMetadata) -> Self {
108 ThreadMetadata {
109 session_id: row.session_id,
110 agent_id: row.agent_id,
111 title: row.title,
112 updated_at: row.updated_at,
113 created_at: row.created_at,
114 folder_paths: row.folder_paths,
115 }
116 }
117}
118
119#[derive(Debug, Clone, PartialEq)]
120struct DbThreadMetadata {
121 pub session_id: acp::SessionId,
122 pub agent_id: AgentId,
123 pub title: SharedString,
124 pub updated_at: DateTime<Utc>,
125 pub created_at: Option<DateTime<Utc>>,
126 pub folder_paths: PathList,
127 pub archived: bool,
128}
129
130impl ThreadMetadata {
131 pub fn from_thread(thread: &Entity<acp_thread::AcpThread>, cx: &App) -> Self {
132 let thread_ref = thread.read(cx);
133 let session_id = thread_ref.session_id().clone();
134 let title = thread_ref
135 .title()
136 .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
137 let updated_at = Utc::now();
138
139 let agent_id = thread_ref.connection().agent_id();
140
141 let folder_paths = {
142 let project = thread_ref.project().read(cx);
143 let paths: Vec<Arc<Path>> = project
144 .visible_worktrees(cx)
145 .map(|worktree| worktree.read(cx).abs_path())
146 .collect();
147 PathList::new(&paths)
148 };
149
150 Self {
151 session_id,
152 agent_id,
153 title,
154 created_at: Some(updated_at), // handled by db `ON CONFLICT`
155 updated_at,
156 folder_paths,
157 }
158 }
159}
160
161/// The store holds all metadata needed to show threads in the sidebar/the archive.
162///
163/// Automatically listens to AcpThread events and updates metadata if it has changed.
164pub struct ThreadMetadataStore {
165 db: ThreadMetadataDb,
166 threads: HashMap<acp::SessionId, ThreadMetadata>,
167 archived_threads: HashMap<acp::SessionId, ThreadMetadata>,
168 threads_by_paths: HashMap<PathList, Vec<ThreadMetadata>>,
169 reload_task: Option<Shared<Task<()>>>,
170 session_subscriptions: HashMap<acp::SessionId, Subscription>,
171 pending_thread_ops_tx: smol::channel::Sender<DbOperation>,
172 _db_operations_task: Task<()>,
173}
174
175#[derive(Debug, PartialEq)]
176enum DbOperation {
177 Upsert(ThreadMetadata, Option<bool>),
178 Delete(acp::SessionId),
179}
180
181impl DbOperation {
182 fn id(&self) -> &acp::SessionId {
183 match self {
184 DbOperation::Upsert(thread, _) => &thread.session_id,
185 DbOperation::Delete(session_id) => session_id,
186 }
187 }
188}
189
190impl ThreadMetadataStore {
191 #[cfg(not(any(test, feature = "test-support")))]
192 pub fn init_global(cx: &mut App) {
193 if cx.has_global::<Self>() {
194 return;
195 }
196
197 let db = ThreadMetadataDb::global(cx);
198 let thread_store = cx.new(|cx| Self::new(db, cx));
199 cx.set_global(GlobalThreadMetadataStore(thread_store));
200 }
201
202 #[cfg(any(test, feature = "test-support"))]
203 pub fn init_global(cx: &mut App) {
204 let thread = std::thread::current();
205 let test_name = thread.name().unwrap_or("unknown_test");
206 let db_name = format!("THREAD_METADATA_DB_{}", test_name);
207 let db = smol::block_on(db::open_test_db::<ThreadMetadataDb>(&db_name));
208 let thread_store = cx.new(|cx| Self::new(ThreadMetadataDb(db), cx));
209 cx.set_global(GlobalThreadMetadataStore(thread_store));
210 }
211
212 pub fn try_global(cx: &App) -> Option<Entity<Self>> {
213 cx.try_global::<GlobalThreadMetadataStore>()
214 .map(|store| store.0.clone())
215 }
216
217 pub fn global(cx: &App) -> Entity<Self> {
218 cx.global::<GlobalThreadMetadataStore>().0.clone()
219 }
220
221 pub fn is_empty(&self) -> bool {
222 self.threads.is_empty()
223 }
224
225 /// Returns all thread IDs.
226 pub fn entry_ids(&self) -> impl Iterator<Item = acp::SessionId> + '_ {
227 self.threads
228 .keys()
229 .chain(self.archived_threads.keys())
230 .cloned()
231 }
232
233 /// Returns all threads.
234 pub fn entries(&self) -> impl Iterator<Item = &ThreadMetadata> + '_ {
235 self.threads.values().chain(self.archived_threads.values())
236 }
237
238 /// Returns all threads that are not archived.
239 pub fn non_archived_entries(&self) -> impl Iterator<Item = &ThreadMetadata> + '_ {
240 self.threads.values()
241 }
242
243 /// Returns all archived threads.
244 pub fn archived_entries(&self) -> impl Iterator<Item = &ThreadMetadata> + '_ {
245 self.archived_threads.values()
246 }
247
248 /// Returns all threads for the given path list, excluding archived threads.
249 pub fn entries_for_path(
250 &self,
251 path_list: &PathList,
252 ) -> impl Iterator<Item = &ThreadMetadata> + '_ {
253 self.threads_by_paths.get(path_list).into_iter().flatten()
254 }
255
256 fn reload(&mut self, cx: &mut Context<Self>) -> Shared<Task<()>> {
257 let db = self.db.clone();
258 self.reload_task.take();
259
260 let list_task = cx
261 .background_spawn(async move { db.list().context("Failed to fetch sidebar metadata") });
262
263 let reload_task = cx
264 .spawn(async move |this, cx| {
265 let Some(rows) = list_task.await.log_err() else {
266 return;
267 };
268
269 this.update(cx, |this, cx| {
270 this.threads.clear();
271 this.threads_by_paths.clear();
272
273 for row in rows {
274 let is_archived = row.archived;
275 let metadata = ThreadMetadata::from(row);
276
277 if is_archived {
278 this.archived_threads
279 .entry(metadata.session_id.clone())
280 .or_insert(metadata);
281 } else {
282 this.threads_by_paths
283 .entry(metadata.folder_paths.clone())
284 .or_default()
285 .push(metadata.clone());
286 this.threads.insert(metadata.session_id.clone(), metadata);
287 }
288 }
289
290 cx.notify();
291 })
292 .ok();
293 })
294 .shared();
295 self.reload_task = Some(reload_task.clone());
296 reload_task
297 }
298
299 pub fn save_all_to_archive(&mut self, metadata: Vec<ThreadMetadata>, cx: &mut Context<Self>) {
300 if !cx.has_flag::<AgentV2FeatureFlag>() {
301 return;
302 }
303
304 for metadata in metadata {
305 self.pending_thread_ops_tx
306 .try_send(DbOperation::Upsert(metadata, Some(true)))
307 .log_err();
308 }
309 }
310
311 pub fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context<Self>) {
312 if !cx.has_flag::<AgentV2FeatureFlag>() {
313 return;
314 }
315
316 self.pending_thread_ops_tx
317 .try_send(DbOperation::Upsert(metadata, None))
318 .log_err();
319 }
320
321 pub fn archive(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
322 self.update_archived(session_id, true, cx);
323 }
324
325 pub fn unarchive(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
326 self.update_archived(session_id, false, cx);
327 }
328
329 fn update_archived(
330 &mut self,
331 session_id: &acp::SessionId,
332 archived: bool,
333 cx: &mut Context<Self>,
334 ) {
335 if !cx.has_flag::<AgentV2FeatureFlag>() {
336 return;
337 }
338
339 if let Some(metadata) = self
340 .entries()
341 .find(|thread| &thread.session_id == session_id)
342 {
343 self.pending_thread_ops_tx
344 .try_send(DbOperation::Upsert(metadata.clone(), Some(archived)))
345 .log_err();
346 }
347 }
348
349 pub fn delete(&mut self, session_id: acp::SessionId, cx: &mut Context<Self>) {
350 if !cx.has_flag::<AgentV2FeatureFlag>() {
351 return;
352 }
353
354 self.pending_thread_ops_tx
355 .try_send(DbOperation::Delete(session_id))
356 .log_err();
357 }
358
359 fn new(db: ThreadMetadataDb, cx: &mut Context<Self>) -> Self {
360 let weak_store = cx.weak_entity();
361
362 cx.observe_new::<acp_thread::AcpThread>(move |thread, _window, cx| {
363 // Don't track subagent threads in the sidebar.
364 if thread.parent_session_id().is_some() {
365 return;
366 }
367
368 let thread_entity = cx.entity();
369
370 cx.on_release({
371 let weak_store = weak_store.clone();
372 move |thread, cx| {
373 weak_store
374 .update(cx, |store, cx| {
375 let session_id = thread.session_id().clone();
376 store.session_subscriptions.remove(&session_id);
377 if thread.entries().is_empty() {
378 // Empty threads can be unloaded without ever being
379 // durably persisted by the underlying agent.
380 store.delete(session_id, cx);
381 }
382 })
383 .ok();
384 }
385 })
386 .detach();
387
388 weak_store
389 .update(cx, |this, cx| {
390 let subscription = cx.subscribe(&thread_entity, Self::handle_thread_update);
391 this.session_subscriptions
392 .insert(thread.session_id().clone(), subscription);
393 })
394 .ok();
395 })
396 .detach();
397
398 let (tx, rx) = smol::channel::unbounded();
399 let _db_operations_task = cx.spawn({
400 let db = db.clone();
401 async move |this, cx| {
402 while let Ok(first_update) = rx.recv().await {
403 let mut updates = vec![first_update];
404 while let Ok(update) = rx.try_recv() {
405 updates.push(update);
406 }
407 let updates = Self::dedup_db_operations(updates);
408 for operation in updates {
409 match operation {
410 DbOperation::Upsert(metadata, archived) => {
411 db.save(metadata, archived).await.log_err();
412 }
413 DbOperation::Delete(session_id) => {
414 db.delete(session_id).await.log_err();
415 }
416 }
417 }
418
419 this.update(cx, |this, cx| this.reload(cx)).ok();
420 }
421 }
422 });
423
424 let mut this = Self {
425 db,
426 threads: HashMap::default(),
427 threads_by_paths: HashMap::default(),
428 archived_threads: HashMap::default(),
429 reload_task: None,
430 session_subscriptions: HashMap::default(),
431 pending_thread_ops_tx: tx,
432 _db_operations_task,
433 };
434 let _ = this.reload(cx);
435 this
436 }
437
438 fn dedup_db_operations(operations: Vec<DbOperation>) -> Vec<DbOperation> {
439 let mut ops = HashMap::default();
440 for operation in operations.into_iter().rev() {
441 if let Some(existing_operation) = ops.get_mut(operation.id()) {
442 match (existing_operation, operation) {
443 (
444 DbOperation::Upsert(_, existing_archive),
445 DbOperation::Upsert(_, Some(archive)),
446 ) if existing_archive.is_none() => {
447 *existing_archive = Some(archive);
448 }
449 _ => {
450 continue;
451 }
452 };
453 } else {
454 ops.insert(operation.id().clone(), operation);
455 }
456 }
457 ops.into_values().collect()
458 }
459
460 fn handle_thread_update(
461 &mut self,
462 thread: Entity<acp_thread::AcpThread>,
463 event: &acp_thread::AcpThreadEvent,
464 cx: &mut Context<Self>,
465 ) {
466 // Don't track subagent threads in the sidebar.
467 if thread.read(cx).parent_session_id().is_some() {
468 return;
469 }
470
471 match event {
472 acp_thread::AcpThreadEvent::NewEntry
473 | acp_thread::AcpThreadEvent::TitleUpdated
474 | acp_thread::AcpThreadEvent::EntryUpdated(_)
475 | acp_thread::AcpThreadEvent::EntriesRemoved(_)
476 | acp_thread::AcpThreadEvent::ToolAuthorizationRequested(_)
477 | acp_thread::AcpThreadEvent::ToolAuthorizationReceived(_)
478 | acp_thread::AcpThreadEvent::Retry(_)
479 | acp_thread::AcpThreadEvent::Stopped(_)
480 | acp_thread::AcpThreadEvent::Error
481 | acp_thread::AcpThreadEvent::LoadError(_)
482 | acp_thread::AcpThreadEvent::Refusal => {
483 let metadata = ThreadMetadata::from_thread(&thread, cx);
484 self.save(metadata, cx);
485 }
486 _ => {}
487 }
488 }
489}
490
491impl Global for ThreadMetadataStore {}
492
493struct ThreadMetadataDb(ThreadSafeConnection);
494
495impl Domain for ThreadMetadataDb {
496 const NAME: &str = stringify!(ThreadMetadataDb);
497
498 const MIGRATIONS: &[&str] = &[
499 sql!(
500 CREATE TABLE IF NOT EXISTS sidebar_threads(
501 session_id TEXT PRIMARY KEY,
502 agent_id TEXT,
503 title TEXT NOT NULL,
504 updated_at TEXT NOT NULL,
505 created_at TEXT,
506 folder_paths TEXT,
507 folder_paths_order TEXT
508 ) STRICT;
509 ),
510 sql!(ALTER TABLE sidebar_threads ADD COLUMN archived INTEGER DEFAULT 0),
511 ];
512}
513
514db::static_connection!(ThreadMetadataDb, []);
515
516impl ThreadMetadataDb {
517 pub fn list_ids(&self) -> anyhow::Result<Vec<Arc<str>>> {
518 self.select::<Arc<str>>(
519 "SELECT session_id FROM sidebar_threads \
520 ORDER BY updated_at DESC",
521 )?()
522 }
523
524 /// List all sidebar thread metadata, ordered by updated_at descending.
525 pub fn list(&self) -> anyhow::Result<Vec<DbThreadMetadata>> {
526 self.select::<DbThreadMetadata>(
527 "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived \
528 FROM sidebar_threads \
529 ORDER BY updated_at DESC"
530 )?()
531 }
532
533 /// Upsert metadata for a thread.
534 pub async fn save(&self, row: ThreadMetadata, archived: Option<bool>) -> anyhow::Result<()> {
535 let id = row.session_id.0.clone();
536 let agent_id = if row.agent_id.as_ref() == ZED_AGENT_ID.as_ref() {
537 None
538 } else {
539 Some(row.agent_id.to_string())
540 };
541 let title = row.title.to_string();
542 let updated_at = row.updated_at.to_rfc3339();
543 let created_at = row.created_at.map(|dt| dt.to_rfc3339());
544 let serialized = row.folder_paths.serialize();
545 let (folder_paths, folder_paths_order) = if row.folder_paths.is_empty() {
546 (None, None)
547 } else {
548 (Some(serialized.paths), Some(serialized.order))
549 };
550
551 self.write(move |conn| {
552 let mut sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived) \
553 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
554 ON CONFLICT(session_id) DO UPDATE SET \
555 agent_id = excluded.agent_id, \
556 title = excluded.title, \
557 updated_at = excluded.updated_at, \
558 folder_paths = excluded.folder_paths, \
559 folder_paths_order = excluded.folder_paths_order".to_string();
560 if archived.is_some() {
561 sql.push_str(", archived = excluded.archived");
562 }
563 let mut stmt = Statement::prepare(conn, sql)?;
564 let mut i = stmt.bind(&id, 1)?;
565 i = stmt.bind(&agent_id, i)?;
566 i = stmt.bind(&title, i)?;
567 i = stmt.bind(&updated_at, i)?;
568 i = stmt.bind(&created_at, i)?;
569 i = stmt.bind(&folder_paths, i)?;
570 i = stmt.bind(&folder_paths_order, i)?;
571 stmt.bind(&archived.unwrap_or(false), i)?;
572 stmt.exec()
573 })
574 .await
575 }
576
577 /// Delete metadata for a single thread.
578 pub async fn delete(&self, session_id: acp::SessionId) -> anyhow::Result<()> {
579 let id = session_id.0.clone();
580 self.write(move |conn| {
581 let mut stmt =
582 Statement::prepare(conn, "DELETE FROM sidebar_threads WHERE session_id = ?")?;
583 stmt.bind(&id, 1)?;
584 stmt.exec()
585 })
586 .await
587 }
588}
589
590impl Column for DbThreadMetadata {
591 fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
592 let (id, next): (Arc<str>, i32) = Column::column(statement, start_index)?;
593 let (agent_id, next): (Option<String>, i32) = Column::column(statement, next)?;
594 let (title, next): (String, i32) = Column::column(statement, next)?;
595 let (updated_at_str, next): (String, i32) = Column::column(statement, next)?;
596 let (created_at_str, next): (Option<String>, i32) = Column::column(statement, next)?;
597 let (folder_paths_str, next): (Option<String>, i32) = Column::column(statement, next)?;
598 let (folder_paths_order_str, next): (Option<String>, i32) =
599 Column::column(statement, next)?;
600 let (archived, next): (bool, i32) = Column::column(statement, next)?;
601
602 let agent_id = agent_id
603 .map(|id| AgentId::new(id))
604 .unwrap_or(ZED_AGENT_ID.clone());
605
606 let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc);
607 let created_at = created_at_str
608 .as_deref()
609 .map(DateTime::parse_from_rfc3339)
610 .transpose()?
611 .map(|dt| dt.with_timezone(&Utc));
612
613 let folder_paths = folder_paths_str
614 .map(|paths| {
615 PathList::deserialize(&util::path_list::SerializedPathList {
616 paths,
617 order: folder_paths_order_str.unwrap_or_default(),
618 })
619 })
620 .unwrap_or_default();
621
622 Ok((
623 DbThreadMetadata {
624 session_id: acp::SessionId::new(id),
625 agent_id,
626 title: title.into(),
627 updated_at,
628 created_at,
629 folder_paths,
630 archived,
631 },
632 next,
633 ))
634 }
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640 use acp_thread::{AgentConnection, StubAgentConnection};
641 use action_log::ActionLog;
642 use agent::DbThread;
643 use agent_client_protocol as acp;
644 use feature_flags::FeatureFlagAppExt;
645 use gpui::TestAppContext;
646 use project::FakeFs;
647 use project::Project;
648 use std::path::Path;
649 use std::rc::Rc;
650
651 fn make_db_thread(title: &str, updated_at: DateTime<Utc>) -> DbThread {
652 DbThread {
653 title: title.to_string().into(),
654 messages: Vec::new(),
655 updated_at,
656 detailed_summary: None,
657 initial_project_snapshot: None,
658 cumulative_token_usage: Default::default(),
659 request_token_usage: Default::default(),
660 model: None,
661 profile: None,
662 imported: false,
663 subagent_context: None,
664 speed: None,
665 thinking_enabled: false,
666 thinking_effort: None,
667 draft_prompt: None,
668 ui_scroll_position: None,
669 }
670 }
671
672 fn make_metadata(
673 session_id: &str,
674 title: &str,
675 updated_at: DateTime<Utc>,
676 folder_paths: PathList,
677 ) -> ThreadMetadata {
678 ThreadMetadata {
679 session_id: acp::SessionId::new(session_id),
680 agent_id: agent::ZED_AGENT_ID.clone(),
681 title: title.to_string().into(),
682 updated_at,
683 created_at: Some(updated_at),
684 folder_paths,
685 }
686 }
687
688 #[gpui::test]
689 async fn test_store_initializes_cache_from_database(cx: &mut TestAppContext) {
690 let first_paths = PathList::new(&[Path::new("/project-a")]);
691 let second_paths = PathList::new(&[Path::new("/project-b")]);
692 let now = Utc::now();
693 let older = now - chrono::Duration::seconds(1);
694
695 let thread = std::thread::current();
696 let test_name = thread.name().unwrap_or("unknown_test");
697 let db_name = format!("THREAD_METADATA_DB_{}", test_name);
698 let db = ThreadMetadataDb(smol::block_on(db::open_test_db::<ThreadMetadataDb>(
699 &db_name,
700 )));
701
702 db.save(
703 make_metadata("session-1", "First Thread", now, first_paths.clone()),
704 None,
705 )
706 .await
707 .unwrap();
708 db.save(
709 make_metadata("session-2", "Second Thread", older, second_paths.clone()),
710 None,
711 )
712 .await
713 .unwrap();
714
715 cx.update(|cx| {
716 let settings_store = settings::SettingsStore::test(cx);
717 cx.set_global(settings_store);
718 cx.update_flags(true, vec!["agent-v2".to_string()]);
719 ThreadMetadataStore::init_global(cx);
720 });
721
722 cx.run_until_parked();
723
724 cx.update(|cx| {
725 let store = ThreadMetadataStore::global(cx);
726 let store = store.read(cx);
727
728 let entry_ids = store
729 .entry_ids()
730 .map(|session_id| session_id.0.to_string())
731 .collect::<Vec<_>>();
732 assert_eq!(entry_ids.len(), 2);
733 assert!(entry_ids.contains(&"session-1".to_string()));
734 assert!(entry_ids.contains(&"session-2".to_string()));
735
736 let first_path_entries = store
737 .entries_for_path(&first_paths)
738 .map(|entry| entry.session_id.0.to_string())
739 .collect::<Vec<_>>();
740 assert_eq!(first_path_entries, vec!["session-1"]);
741
742 let second_path_entries = store
743 .entries_for_path(&second_paths)
744 .map(|entry| entry.session_id.0.to_string())
745 .collect::<Vec<_>>();
746 assert_eq!(second_path_entries, vec!["session-2"]);
747 });
748 }
749
750 #[gpui::test]
751 async fn test_store_cache_updates_after_save_and_delete(cx: &mut TestAppContext) {
752 cx.update(|cx| {
753 let settings_store = settings::SettingsStore::test(cx);
754 cx.set_global(settings_store);
755 cx.update_flags(true, vec!["agent-v2".to_string()]);
756 ThreadMetadataStore::init_global(cx);
757 });
758
759 let first_paths = PathList::new(&[Path::new("/project-a")]);
760 let second_paths = PathList::new(&[Path::new("/project-b")]);
761 let initial_time = Utc::now();
762 let updated_time = initial_time + chrono::Duration::seconds(1);
763
764 let initial_metadata = make_metadata(
765 "session-1",
766 "First Thread",
767 initial_time,
768 first_paths.clone(),
769 );
770
771 let second_metadata = make_metadata(
772 "session-2",
773 "Second Thread",
774 initial_time,
775 second_paths.clone(),
776 );
777
778 cx.update(|cx| {
779 let store = ThreadMetadataStore::global(cx);
780 store.update(cx, |store, cx| {
781 store.save(initial_metadata, cx);
782 store.save(second_metadata, cx);
783 });
784 });
785
786 cx.run_until_parked();
787
788 cx.update(|cx| {
789 let store = ThreadMetadataStore::global(cx);
790 let store = store.read(cx);
791
792 let first_path_entries = store
793 .entries_for_path(&first_paths)
794 .map(|entry| entry.session_id.0.to_string())
795 .collect::<Vec<_>>();
796 assert_eq!(first_path_entries, vec!["session-1"]);
797
798 let second_path_entries = store
799 .entries_for_path(&second_paths)
800 .map(|entry| entry.session_id.0.to_string())
801 .collect::<Vec<_>>();
802 assert_eq!(second_path_entries, vec!["session-2"]);
803 });
804
805 let moved_metadata = make_metadata(
806 "session-1",
807 "First Thread",
808 updated_time,
809 second_paths.clone(),
810 );
811
812 cx.update(|cx| {
813 let store = ThreadMetadataStore::global(cx);
814 store.update(cx, |store, cx| {
815 store.save(moved_metadata, cx);
816 });
817 });
818
819 cx.run_until_parked();
820
821 cx.update(|cx| {
822 let store = ThreadMetadataStore::global(cx);
823 let store = store.read(cx);
824
825 let entry_ids = store
826 .entry_ids()
827 .map(|session_id| session_id.0.to_string())
828 .collect::<Vec<_>>();
829 assert_eq!(entry_ids.len(), 2);
830 assert!(entry_ids.contains(&"session-1".to_string()));
831 assert!(entry_ids.contains(&"session-2".to_string()));
832
833 let first_path_entries = store
834 .entries_for_path(&first_paths)
835 .map(|entry| entry.session_id.0.to_string())
836 .collect::<Vec<_>>();
837 assert!(first_path_entries.is_empty());
838
839 let second_path_entries = store
840 .entries_for_path(&second_paths)
841 .map(|entry| entry.session_id.0.to_string())
842 .collect::<Vec<_>>();
843 assert_eq!(second_path_entries.len(), 2);
844 assert!(second_path_entries.contains(&"session-1".to_string()));
845 assert!(second_path_entries.contains(&"session-2".to_string()));
846 });
847
848 cx.update(|cx| {
849 let store = ThreadMetadataStore::global(cx);
850 store.update(cx, |store, cx| {
851 store.delete(acp::SessionId::new("session-2"), cx);
852 });
853 });
854
855 cx.run_until_parked();
856
857 cx.update(|cx| {
858 let store = ThreadMetadataStore::global(cx);
859 let store = store.read(cx);
860
861 let entry_ids = store
862 .entry_ids()
863 .map(|session_id| session_id.0.to_string())
864 .collect::<Vec<_>>();
865 assert_eq!(entry_ids, vec!["session-1"]);
866
867 let second_path_entries = store
868 .entries_for_path(&second_paths)
869 .map(|entry| entry.session_id.0.to_string())
870 .collect::<Vec<_>>();
871 assert_eq!(second_path_entries, vec!["session-1"]);
872 });
873 }
874
875 #[gpui::test]
876 async fn test_migrate_thread_metadata_migrates_only_missing_threads(cx: &mut TestAppContext) {
877 cx.update(|cx| {
878 ThreadStore::init_global(cx);
879 ThreadMetadataStore::init_global(cx);
880 });
881
882 let project_a_paths = PathList::new(&[Path::new("/project-a")]);
883 let project_b_paths = PathList::new(&[Path::new("/project-b")]);
884 let now = Utc::now();
885
886 let existing_metadata = ThreadMetadata {
887 session_id: acp::SessionId::new("a-session-0"),
888 agent_id: agent::ZED_AGENT_ID.clone(),
889 title: "Existing Metadata".into(),
890 updated_at: now - chrono::Duration::seconds(10),
891 created_at: Some(now - chrono::Duration::seconds(10)),
892 folder_paths: project_a_paths.clone(),
893 };
894
895 cx.update(|cx| {
896 let store = ThreadMetadataStore::global(cx);
897 store.update(cx, |store, cx| {
898 store.save(existing_metadata, cx);
899 });
900 });
901 cx.run_until_parked();
902
903 let threads_to_save = vec![
904 (
905 "a-session-0",
906 "Thread A0 From Native Store",
907 project_a_paths.clone(),
908 now,
909 ),
910 (
911 "a-session-1",
912 "Thread A1",
913 project_a_paths.clone(),
914 now + chrono::Duration::seconds(1),
915 ),
916 (
917 "b-session-0",
918 "Thread B0",
919 project_b_paths.clone(),
920 now + chrono::Duration::seconds(2),
921 ),
922 (
923 "projectless",
924 "Projectless",
925 PathList::default(),
926 now + chrono::Duration::seconds(3),
927 ),
928 ];
929
930 for (session_id, title, paths, updated_at) in &threads_to_save {
931 let save_task = cx.update(|cx| {
932 let thread_store = ThreadStore::global(cx);
933 let session_id = session_id.to_string();
934 let title = title.to_string();
935 let paths = paths.clone();
936 thread_store.update(cx, |store, cx| {
937 store.save_thread(
938 acp::SessionId::new(session_id),
939 make_db_thread(&title, *updated_at),
940 paths,
941 cx,
942 )
943 })
944 });
945 save_task.await.unwrap();
946 cx.run_until_parked();
947 }
948
949 cx.update(|cx| migrate_thread_metadata(cx));
950 cx.run_until_parked();
951
952 let list = cx.update(|cx| {
953 let store = ThreadMetadataStore::global(cx);
954 store.read(cx).entries().cloned().collect::<Vec<_>>()
955 });
956
957 assert_eq!(list.len(), 3);
958 assert!(
959 list.iter()
960 .all(|metadata| metadata.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref())
961 );
962
963 let non_archived = cx.update(|cx| {
964 let store = ThreadMetadataStore::global(cx);
965 store
966 .read(cx)
967 .non_archived_entries()
968 .cloned()
969 .collect::<Vec<_>>()
970 });
971
972 assert_eq!(non_archived.len(), 1);
973 let existing_metadata = non_archived
974 .iter()
975 .find(|metadata| metadata.session_id.0.as_ref() == "a-session-0")
976 .unwrap();
977 assert_eq!(existing_metadata.title.as_ref(), "Existing Metadata");
978
979 let archived = cx.update(|cx| {
980 let store = ThreadMetadataStore::global(cx);
981 store
982 .read(cx)
983 .archived_entries()
984 .cloned()
985 .collect::<Vec<_>>()
986 });
987
988 assert_eq!(archived.len(), 2);
989 let migrated_session_ids = archived
990 .iter()
991 .map(|metadata| metadata.session_id.0.as_ref())
992 .collect::<Vec<_>>();
993 assert!(migrated_session_ids.contains(&"a-session-1"));
994 assert!(migrated_session_ids.contains(&"b-session-0"));
995 assert!(!migrated_session_ids.contains(&"projectless"));
996
997 let migrated_entries = list
998 .iter()
999 .filter(|metadata| metadata.session_id.0.as_ref() != "a-session-0")
1000 .collect::<Vec<_>>();
1001 assert!(
1002 migrated_entries
1003 .iter()
1004 .all(|metadata| !metadata.folder_paths.is_empty())
1005 );
1006 }
1007
1008 #[gpui::test]
1009 async fn test_migrate_thread_metadata_noops_when_all_threads_already_exist(
1010 cx: &mut TestAppContext,
1011 ) {
1012 cx.update(|cx| {
1013 ThreadStore::init_global(cx);
1014 ThreadMetadataStore::init_global(cx);
1015 });
1016
1017 let project_paths = PathList::new(&[Path::new("/project-a")]);
1018 let existing_updated_at = Utc::now();
1019
1020 let existing_metadata = ThreadMetadata {
1021 session_id: acp::SessionId::new("existing-session"),
1022 agent_id: agent::ZED_AGENT_ID.clone(),
1023 title: "Existing Metadata".into(),
1024 updated_at: existing_updated_at,
1025 created_at: Some(existing_updated_at),
1026 folder_paths: project_paths.clone(),
1027 };
1028
1029 cx.update(|cx| {
1030 let store = ThreadMetadataStore::global(cx);
1031 store.update(cx, |store, cx| {
1032 store.save(existing_metadata, cx);
1033 });
1034 });
1035 cx.run_until_parked();
1036
1037 let save_task = cx.update(|cx| {
1038 let thread_store = ThreadStore::global(cx);
1039 thread_store.update(cx, |store, cx| {
1040 store.save_thread(
1041 acp::SessionId::new("existing-session"),
1042 make_db_thread(
1043 "Updated Native Thread Title",
1044 existing_updated_at + chrono::Duration::seconds(1),
1045 ),
1046 project_paths.clone(),
1047 cx,
1048 )
1049 })
1050 });
1051 save_task.await.unwrap();
1052 cx.run_until_parked();
1053
1054 cx.update(|cx| migrate_thread_metadata(cx));
1055 cx.run_until_parked();
1056
1057 let list = cx.update(|cx| {
1058 let store = ThreadMetadataStore::global(cx);
1059 store.read(cx).entries().cloned().collect::<Vec<_>>()
1060 });
1061
1062 assert_eq!(list.len(), 1);
1063 assert_eq!(list[0].session_id.0.as_ref(), "existing-session");
1064 }
1065 #[gpui::test]
1066 async fn test_empty_thread_metadata_deleted_when_thread_released(cx: &mut TestAppContext) {
1067 cx.update(|cx| {
1068 let settings_store = settings::SettingsStore::test(cx);
1069 cx.set_global(settings_store);
1070 cx.update_flags(true, vec!["agent-v2".to_string()]);
1071 ThreadStore::init_global(cx);
1072 ThreadMetadataStore::init_global(cx);
1073 });
1074
1075 let fs = FakeFs::new(cx.executor());
1076 let project = Project::test(fs, None::<&Path>, cx).await;
1077 let connection = Rc::new(StubAgentConnection::new());
1078
1079 let thread = cx
1080 .update(|cx| {
1081 connection
1082 .clone()
1083 .new_session(project.clone(), PathList::default(), cx)
1084 })
1085 .await
1086 .unwrap();
1087 let session_id = cx.read(|cx| thread.read(cx).session_id().clone());
1088
1089 cx.update(|cx| {
1090 thread.update(cx, |thread, cx| {
1091 thread.set_title("Draft Thread".into(), cx).detach();
1092 });
1093 });
1094 cx.run_until_parked();
1095
1096 let metadata_ids = cx.update(|cx| {
1097 ThreadMetadataStore::global(cx)
1098 .read(cx)
1099 .entry_ids()
1100 .collect::<Vec<_>>()
1101 });
1102 assert_eq!(metadata_ids, vec![session_id]);
1103
1104 drop(thread);
1105 cx.update(|_| {});
1106 cx.run_until_parked();
1107 cx.run_until_parked();
1108
1109 let metadata_ids = cx.update(|cx| {
1110 ThreadMetadataStore::global(cx)
1111 .read(cx)
1112 .entry_ids()
1113 .collect::<Vec<_>>()
1114 });
1115 assert!(
1116 metadata_ids.is_empty(),
1117 "expected empty draft thread metadata to be deleted on release"
1118 );
1119 }
1120
1121 #[gpui::test]
1122 async fn test_nonempty_thread_metadata_preserved_when_thread_released(cx: &mut TestAppContext) {
1123 cx.update(|cx| {
1124 let settings_store = settings::SettingsStore::test(cx);
1125 cx.set_global(settings_store);
1126 cx.update_flags(true, vec!["agent-v2".to_string()]);
1127 ThreadStore::init_global(cx);
1128 ThreadMetadataStore::init_global(cx);
1129 });
1130
1131 let fs = FakeFs::new(cx.executor());
1132 let project = Project::test(fs, None::<&Path>, cx).await;
1133 let connection = Rc::new(StubAgentConnection::new());
1134
1135 let thread = cx
1136 .update(|cx| {
1137 connection
1138 .clone()
1139 .new_session(project.clone(), PathList::default(), cx)
1140 })
1141 .await
1142 .unwrap();
1143 let session_id = cx.read(|cx| thread.read(cx).session_id().clone());
1144
1145 cx.update(|cx| {
1146 thread.update(cx, |thread, cx| {
1147 thread.push_user_content_block(None, "Hello".into(), cx);
1148 });
1149 });
1150 cx.run_until_parked();
1151
1152 let metadata_ids = cx.update(|cx| {
1153 ThreadMetadataStore::global(cx)
1154 .read(cx)
1155 .entry_ids()
1156 .collect::<Vec<_>>()
1157 });
1158 assert_eq!(metadata_ids, vec![session_id.clone()]);
1159
1160 drop(thread);
1161 cx.update(|_| {});
1162 cx.run_until_parked();
1163
1164 let metadata_ids = cx.update(|cx| {
1165 ThreadMetadataStore::global(cx)
1166 .read(cx)
1167 .entry_ids()
1168 .collect::<Vec<_>>()
1169 });
1170 assert_eq!(metadata_ids, vec![session_id]);
1171 }
1172
1173 #[gpui::test]
1174 async fn test_subagent_threads_excluded_from_sidebar_metadata(cx: &mut TestAppContext) {
1175 cx.update(|cx| {
1176 let settings_store = settings::SettingsStore::test(cx);
1177 cx.set_global(settings_store);
1178 cx.update_flags(true, vec!["agent-v2".to_string()]);
1179 ThreadStore::init_global(cx);
1180 ThreadMetadataStore::init_global(cx);
1181 });
1182
1183 let fs = FakeFs::new(cx.executor());
1184 let project = Project::test(fs, None::<&Path>, cx).await;
1185 let connection = Rc::new(StubAgentConnection::new());
1186
1187 // Create a regular (non-subagent) AcpThread.
1188 let regular_thread = cx
1189 .update(|cx| {
1190 connection
1191 .clone()
1192 .new_session(project.clone(), PathList::default(), cx)
1193 })
1194 .await
1195 .unwrap();
1196
1197 let regular_session_id = cx.read(|cx| regular_thread.read(cx).session_id().clone());
1198
1199 // Set a title on the regular thread to trigger a save via handle_thread_update.
1200 cx.update(|cx| {
1201 regular_thread.update(cx, |thread, cx| {
1202 thread.set_title("Regular Thread".into(), cx).detach();
1203 });
1204 });
1205 cx.run_until_parked();
1206
1207 // Create a subagent AcpThread
1208 let subagent_session_id = acp::SessionId::new("subagent-session");
1209 let subagent_thread = cx.update(|cx| {
1210 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1211 cx.new(|cx| {
1212 acp_thread::AcpThread::new(
1213 Some(regular_session_id.clone()),
1214 Some("Subagent Thread".into()),
1215 None,
1216 connection.clone(),
1217 project.clone(),
1218 action_log,
1219 subagent_session_id.clone(),
1220 watch::Receiver::constant(acp::PromptCapabilities::new()),
1221 cx,
1222 )
1223 })
1224 });
1225
1226 // Set a title on the subagent thread to trigger handle_thread_update.
1227 cx.update(|cx| {
1228 subagent_thread.update(cx, |thread, cx| {
1229 thread
1230 .set_title("Subagent Thread Title".into(), cx)
1231 .detach();
1232 });
1233 });
1234 cx.run_until_parked();
1235
1236 // List all metadata from the store cache.
1237 let list = cx.update(|cx| {
1238 let store = ThreadMetadataStore::global(cx);
1239 store.read(cx).entries().cloned().collect::<Vec<_>>()
1240 });
1241
1242 // The subagent thread should NOT appear in the sidebar metadata.
1243 // Only the regular thread should be listed.
1244 assert_eq!(
1245 list.len(),
1246 1,
1247 "Expected only the regular thread in sidebar metadata, \
1248 but found {} entries (subagent threads are leaking into the sidebar)",
1249 list.len(),
1250 );
1251 assert_eq!(list[0].session_id, regular_session_id);
1252 assert_eq!(list[0].title.as_ref(), "Regular Thread");
1253 }
1254
1255 #[test]
1256 fn test_dedup_db_operations_keeps_latest_operation_for_session() {
1257 let now = Utc::now();
1258
1259 let operations = vec![
1260 DbOperation::Upsert(
1261 make_metadata("session-1", "First Thread", now, PathList::default()),
1262 None,
1263 ),
1264 DbOperation::Delete(acp::SessionId::new("session-1")),
1265 ];
1266
1267 let deduped = ThreadMetadataStore::dedup_db_operations(operations);
1268
1269 assert_eq!(deduped.len(), 1);
1270 assert_eq!(
1271 deduped[0],
1272 DbOperation::Delete(acp::SessionId::new("session-1"))
1273 );
1274 }
1275
1276 #[test]
1277 fn test_dedup_db_operations_keeps_latest_insert_for_same_session() {
1278 let now = Utc::now();
1279 let later = now + chrono::Duration::seconds(1);
1280
1281 let old_metadata = make_metadata("session-1", "Old Title", now, PathList::default());
1282 let new_metadata = make_metadata("session-1", "New Title", later, PathList::default());
1283
1284 let deduped = ThreadMetadataStore::dedup_db_operations(vec![
1285 DbOperation::Upsert(old_metadata, None),
1286 DbOperation::Upsert(new_metadata.clone(), None),
1287 ]);
1288
1289 assert_eq!(deduped.len(), 1);
1290 assert_eq!(deduped[0], DbOperation::Upsert(new_metadata, None));
1291 }
1292
1293 #[test]
1294 fn test_dedup_db_operations_preserves_distinct_sessions() {
1295 let now = Utc::now();
1296
1297 let metadata1 = make_metadata("session-1", "First Thread", now, PathList::default());
1298 let metadata2 = make_metadata("session-2", "Second Thread", now, PathList::default());
1299 let deduped = ThreadMetadataStore::dedup_db_operations(vec![
1300 DbOperation::Upsert(metadata1.clone(), None),
1301 DbOperation::Upsert(metadata2.clone(), None),
1302 ]);
1303
1304 assert_eq!(deduped.len(), 2);
1305 assert!(deduped.contains(&DbOperation::Upsert(metadata1, None)));
1306 assert!(deduped.contains(&DbOperation::Upsert(metadata2, None)));
1307 }
1308
1309 #[gpui::test]
1310 async fn test_archive_and_unarchive_thread(cx: &mut TestAppContext) {
1311 cx.update(|cx| {
1312 let settings_store = settings::SettingsStore::test(cx);
1313 cx.set_global(settings_store);
1314 cx.update_flags(true, vec!["agent-v2".to_string()]);
1315 ThreadMetadataStore::init_global(cx);
1316 });
1317
1318 let paths = PathList::new(&[Path::new("/project-a")]);
1319 let now = Utc::now();
1320 let metadata = make_metadata("session-1", "Thread 1", now, paths.clone());
1321
1322 cx.update(|cx| {
1323 let store = ThreadMetadataStore::global(cx);
1324 store.update(cx, |store, cx| {
1325 store.save(metadata, cx);
1326 });
1327 });
1328
1329 cx.run_until_parked();
1330
1331 cx.update(|cx| {
1332 let store = ThreadMetadataStore::global(cx);
1333 let store = store.read(cx);
1334
1335 let path_entries = store
1336 .entries_for_path(&paths)
1337 .map(|e| e.session_id.0.to_string())
1338 .collect::<Vec<_>>();
1339 assert_eq!(path_entries, vec!["session-1"]);
1340
1341 let archived = store
1342 .archived_entries()
1343 .map(|e| e.session_id.0.to_string())
1344 .collect::<Vec<_>>();
1345 assert!(archived.is_empty());
1346 });
1347
1348 cx.update(|cx| {
1349 let store = ThreadMetadataStore::global(cx);
1350 store.update(cx, |store, cx| {
1351 store.archive(&acp::SessionId::new("session-1"), cx);
1352 });
1353 });
1354
1355 cx.run_until_parked();
1356
1357 cx.update(|cx| {
1358 let store = ThreadMetadataStore::global(cx);
1359 let store = store.read(cx);
1360
1361 let path_entries = store
1362 .entries_for_path(&paths)
1363 .map(|e| e.session_id.0.to_string())
1364 .collect::<Vec<_>>();
1365 assert!(path_entries.is_empty());
1366
1367 let archived = store.archived_entries().collect::<Vec<_>>();
1368 assert_eq!(archived.len(), 1);
1369 assert_eq!(archived[0].session_id.0.as_ref(), "session-1");
1370 });
1371
1372 cx.update(|cx| {
1373 let store = ThreadMetadataStore::global(cx);
1374 store.update(cx, |store, cx| {
1375 store.unarchive(&acp::SessionId::new("session-1"), cx);
1376 });
1377 });
1378
1379 cx.run_until_parked();
1380
1381 cx.update(|cx| {
1382 let store = ThreadMetadataStore::global(cx);
1383 let store = store.read(cx);
1384
1385 let path_entries = store
1386 .entries_for_path(&paths)
1387 .map(|e| e.session_id.0.to_string())
1388 .collect::<Vec<_>>();
1389 assert_eq!(path_entries, vec!["session-1"]);
1390
1391 let archived = store
1392 .archived_entries()
1393 .map(|e| e.session_id.0.to_string())
1394 .collect::<Vec<_>>();
1395 dbg!(&archived);
1396 assert!(archived.is_empty());
1397 });
1398 }
1399
1400 #[gpui::test]
1401 async fn test_entries_for_path_excludes_archived(cx: &mut TestAppContext) {
1402 cx.update(|cx| {
1403 let settings_store = settings::SettingsStore::test(cx);
1404 cx.set_global(settings_store);
1405 cx.update_flags(true, vec!["agent-v2".to_string()]);
1406 ThreadMetadataStore::init_global(cx);
1407 });
1408
1409 let paths = PathList::new(&[Path::new("/project-a")]);
1410 let now = Utc::now();
1411
1412 let metadata1 = make_metadata("session-1", "Active Thread", now, paths.clone());
1413 let metadata2 = make_metadata(
1414 "session-2",
1415 "Archived Thread",
1416 now - chrono::Duration::seconds(1),
1417 paths.clone(),
1418 );
1419
1420 cx.update(|cx| {
1421 let store = ThreadMetadataStore::global(cx);
1422 store.update(cx, |store, cx| {
1423 store.save(metadata1, cx);
1424 store.save(metadata2, cx);
1425 });
1426 });
1427
1428 cx.run_until_parked();
1429
1430 cx.update(|cx| {
1431 let store = ThreadMetadataStore::global(cx);
1432 store.update(cx, |store, cx| {
1433 store.archive(&acp::SessionId::new("session-2"), cx);
1434 });
1435 });
1436
1437 cx.run_until_parked();
1438
1439 cx.update(|cx| {
1440 let store = ThreadMetadataStore::global(cx);
1441 let store = store.read(cx);
1442
1443 let path_entries = store
1444 .entries_for_path(&paths)
1445 .map(|e| e.session_id.0.to_string())
1446 .collect::<Vec<_>>();
1447 assert_eq!(path_entries, vec!["session-1"]);
1448
1449 let all_entries = store
1450 .entries()
1451 .map(|e| e.session_id.0.to_string())
1452 .collect::<Vec<_>>();
1453 assert_eq!(all_entries.len(), 2);
1454 assert!(all_entries.contains(&"session-1".to_string()));
1455 assert!(all_entries.contains(&"session-2".to_string()));
1456
1457 let archived = store
1458 .archived_entries()
1459 .map(|e| e.session_id.0.to_string())
1460 .collect::<Vec<_>>();
1461 assert_eq!(archived, vec!["session-2"]);
1462 });
1463 }
1464
1465 #[gpui::test]
1466 async fn test_save_all_persists_multiple_threads(cx: &mut TestAppContext) {
1467 cx.update(|cx| {
1468 let settings_store = settings::SettingsStore::test(cx);
1469 cx.set_global(settings_store);
1470 cx.update_flags(true, vec!["agent-v2".to_string()]);
1471 ThreadMetadataStore::init_global(cx);
1472 });
1473
1474 let paths = PathList::new(&[Path::new("/project-a")]);
1475 let now = Utc::now();
1476
1477 let m1 = make_metadata("session-1", "Thread One", now, paths.clone());
1478 let m2 = make_metadata(
1479 "session-2",
1480 "Thread Two",
1481 now - chrono::Duration::seconds(1),
1482 paths.clone(),
1483 );
1484 let m3 = make_metadata(
1485 "session-3",
1486 "Thread Three",
1487 now - chrono::Duration::seconds(2),
1488 paths,
1489 );
1490
1491 cx.update(|cx| {
1492 let store = ThreadMetadataStore::global(cx);
1493 store.update(cx, |store, cx| {
1494 store.save_all_to_archive(vec![m1, m2, m3], cx);
1495 });
1496 });
1497
1498 cx.run_until_parked();
1499
1500 cx.update(|cx| {
1501 let store = ThreadMetadataStore::global(cx);
1502 let store = store.read(cx);
1503
1504 let all_entries = store
1505 .entries()
1506 .map(|e| e.session_id.0.to_string())
1507 .collect::<Vec<_>>();
1508 assert_eq!(all_entries.len(), 3);
1509 assert!(all_entries.contains(&"session-1".to_string()));
1510 assert!(all_entries.contains(&"session-2".to_string()));
1511 assert!(all_entries.contains(&"session-3".to_string()));
1512
1513 let entry_ids = store.entry_ids().collect::<Vec<_>>();
1514 assert_eq!(entry_ids.len(), 3);
1515 });
1516 }
1517
1518 #[gpui::test]
1519 async fn test_archived_flag_persists_across_reload(cx: &mut TestAppContext) {
1520 cx.update(|cx| {
1521 let settings_store = settings::SettingsStore::test(cx);
1522 cx.set_global(settings_store);
1523 cx.update_flags(true, vec!["agent-v2".to_string()]);
1524 ThreadMetadataStore::init_global(cx);
1525 });
1526
1527 let paths = PathList::new(&[Path::new("/project-a")]);
1528 let now = Utc::now();
1529 let metadata = make_metadata("session-1", "Thread 1", now, paths.clone());
1530
1531 cx.update(|cx| {
1532 let store = ThreadMetadataStore::global(cx);
1533 store.update(cx, |store, cx| {
1534 store.save(metadata, cx);
1535 });
1536 });
1537
1538 cx.run_until_parked();
1539
1540 cx.update(|cx| {
1541 let store = ThreadMetadataStore::global(cx);
1542 store.update(cx, |store, cx| {
1543 store.archive(&acp::SessionId::new("session-1"), cx);
1544 });
1545 });
1546
1547 cx.run_until_parked();
1548
1549 cx.update(|cx| {
1550 let store = ThreadMetadataStore::global(cx);
1551 store.update(cx, |store, cx| {
1552 let _ = store.reload(cx);
1553 });
1554 });
1555
1556 cx.run_until_parked();
1557
1558 cx.update(|cx| {
1559 let store = ThreadMetadataStore::global(cx);
1560 let store = store.read(cx);
1561
1562 assert!(
1563 store
1564 .entries()
1565 .find(|e| e.session_id.0.as_ref() == "session-1")
1566 .is_some(),
1567 "thread should exist after reload"
1568 );
1569
1570 let path_entries = store
1571 .entries_for_path(&paths)
1572 .map(|e| e.session_id.0.to_string())
1573 .collect::<Vec<_>>();
1574 assert!(path_entries.is_empty());
1575
1576 let archived = store
1577 .archived_entries()
1578 .map(|e| e.session_id.0.to_string())
1579 .collect::<Vec<_>>();
1580 assert_eq!(archived, vec!["session-1"]);
1581 });
1582 }
1583
1584 #[gpui::test]
1585 async fn test_archive_nonexistent_thread_is_noop(cx: &mut TestAppContext) {
1586 cx.update(|cx| {
1587 let settings_store = settings::SettingsStore::test(cx);
1588 cx.set_global(settings_store);
1589 cx.update_flags(true, vec!["agent-v2".to_string()]);
1590 ThreadMetadataStore::init_global(cx);
1591 });
1592
1593 cx.run_until_parked();
1594
1595 cx.update(|cx| {
1596 let store = ThreadMetadataStore::global(cx);
1597 store.update(cx, |store, cx| {
1598 store.archive(&acp::SessionId::new("nonexistent"), cx);
1599 });
1600 });
1601
1602 cx.run_until_parked();
1603
1604 cx.update(|cx| {
1605 let store = ThreadMetadataStore::global(cx);
1606 let store = store.read(cx);
1607
1608 assert!(store.is_empty());
1609 assert_eq!(store.entries().count(), 0);
1610 assert_eq!(store.archived_entries().count(), 0);
1611 });
1612 }
1613}