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