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