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