1use std::{path::Path, sync::Arc};
2
3use acp_thread::AgentSessionInfo;
4use agent::{ThreadStore, ZED_AGENT_ID};
5use agent_client_protocol as acp;
6use anyhow::{Context as _, Result};
7use chrono::{DateTime, Utc};
8use collections::HashMap;
9use db::{
10 sqlez::{
11 bindable::Column, domain::Domain, statement::Statement,
12 thread_safe_connection::ThreadSafeConnection,
13 },
14 sqlez_macros::sql,
15};
16use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
17use futures::{FutureExt as _, future::Shared};
18use gpui::{AppContext as _, Entity, Global, Subscription, Task};
19use project::AgentId;
20use ui::{App, Context, SharedString};
21use util::ResultExt as _;
22use workspace::PathList;
23
24pub fn init(cx: &mut App) {
25 SidebarThreadMetadataStore::init_global(cx);
26
27 if cx.has_flag::<AgentV2FeatureFlag>() {
28 migrate_thread_metadata(cx);
29 }
30 cx.observe_flag::<AgentV2FeatureFlag, _>(|has_flag, cx| {
31 if has_flag {
32 migrate_thread_metadata(cx);
33 }
34 })
35 .detach();
36}
37
38/// Migrate existing thread metadata from native agent thread store to the new metadata storage.
39///
40/// TODO: Remove this after N weeks of shipping the sidebar
41fn migrate_thread_metadata(cx: &mut App) {
42 let store = SidebarThreadMetadataStore::global(cx);
43 let db = store.read(cx).db.clone();
44
45 cx.spawn(async move |cx| {
46 if !db.is_empty()? {
47 return Ok::<(), anyhow::Error>(());
48 }
49
50 let metadata = store.read_with(cx, |_store, app| {
51 ThreadStore::global(app)
52 .read(app)
53 .entries()
54 .map(|entry| ThreadMetadata {
55 session_id: entry.id,
56 agent_id: None,
57 title: entry.title,
58 updated_at: entry.updated_at,
59 created_at: entry.created_at,
60 folder_paths: entry.folder_paths,
61 })
62 .collect::<Vec<_>>()
63 });
64
65 // Manually save each entry to the database and call reload, otherwise
66 // we'll end up triggering lots of reloads after each save
67 for entry in metadata {
68 db.save(entry).await?;
69 }
70
71 let _ = store.update(cx, |store, cx| store.reload(cx));
72 Ok(())
73 })
74 .detach_and_log_err(cx);
75}
76
77struct GlobalThreadMetadataStore(Entity<SidebarThreadMetadataStore>);
78impl Global for GlobalThreadMetadataStore {}
79
80/// Lightweight metadata for any thread (native or ACP), enough to populate
81/// the sidebar list and route to the correct load path when clicked.
82#[derive(Debug, Clone)]
83pub struct ThreadMetadata {
84 pub session_id: acp::SessionId,
85 /// `None` for native Zed threads, `Some("claude-code")` etc. for ACP agents.
86 pub agent_id: Option<AgentId>,
87 pub title: SharedString,
88 pub updated_at: DateTime<Utc>,
89 pub created_at: Option<DateTime<Utc>>,
90 pub folder_paths: PathList,
91}
92
93impl ThreadMetadata {
94 pub fn from_session_info(agent_id: AgentId, session: &AgentSessionInfo) -> Self {
95 let session_id = session.session_id.clone();
96 let title = session.title.clone().unwrap_or_default();
97 let updated_at = session.updated_at.unwrap_or_else(|| Utc::now());
98 let created_at = session.created_at.unwrap_or(updated_at);
99 let folder_paths = session.work_dirs.clone().unwrap_or_default();
100 let agent_id = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() {
101 None
102 } else {
103 Some(agent_id)
104 };
105 Self {
106 session_id,
107 agent_id,
108 title,
109 updated_at,
110 created_at: Some(created_at),
111 folder_paths,
112 }
113 }
114
115 pub fn from_thread(thread: &Entity<acp_thread::AcpThread>, cx: &App) -> Self {
116 let thread_ref = thread.read(cx);
117 let session_id = thread_ref.session_id().clone();
118 let title = thread_ref.title();
119 let updated_at = Utc::now();
120
121 let agent_id = thread_ref.connection().agent_id();
122
123 let agent_id = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() {
124 None
125 } else {
126 Some(agent_id)
127 };
128
129 let folder_paths = {
130 let project = thread_ref.project().read(cx);
131 let paths: Vec<Arc<Path>> = project
132 .visible_worktrees(cx)
133 .map(|worktree| worktree.read(cx).abs_path())
134 .collect();
135 PathList::new(&paths)
136 };
137
138 Self {
139 session_id,
140 agent_id,
141 title,
142 created_at: Some(updated_at), // handled by db `ON CONFLICT`
143 updated_at,
144 folder_paths,
145 }
146 }
147}
148
149/// The store holds all metadata needed to show threads in the sidebar.
150/// Effectively, all threads stored in here are "non-archived".
151///
152/// Automatically listens to AcpThread events and updates metadata if it has changed.
153pub struct SidebarThreadMetadataStore {
154 db: ThreadMetadataDb,
155 threads: Vec<ThreadMetadata>,
156 threads_by_paths: HashMap<PathList, Vec<ThreadMetadata>>,
157 reload_task: Option<Shared<Task<()>>>,
158 session_subscriptions: HashMap<acp::SessionId, Subscription>,
159}
160
161impl SidebarThreadMetadataStore {
162 #[cfg(not(any(test, feature = "test-support")))]
163 pub fn init_global(cx: &mut App) {
164 if cx.has_global::<Self>() {
165 return;
166 }
167
168 let db = ThreadMetadataDb::global(cx);
169 let thread_store = cx.new(|cx| Self::new(db, cx));
170 cx.set_global(GlobalThreadMetadataStore(thread_store));
171 }
172
173 #[cfg(any(test, feature = "test-support"))]
174 pub fn init_global(cx: &mut App) {
175 let thread = std::thread::current();
176 let test_name = thread.name().unwrap_or("unknown_test");
177 let db_name = format!("THREAD_METADATA_DB_{}", test_name);
178 let db = smol::block_on(db::open_test_db::<ThreadMetadataDb>(&db_name));
179 let thread_store = cx.new(|cx| Self::new(ThreadMetadataDb(db), cx));
180 cx.set_global(GlobalThreadMetadataStore(thread_store));
181 }
182
183 pub fn try_global(cx: &App) -> Option<Entity<Self>> {
184 cx.try_global::<GlobalThreadMetadataStore>()
185 .map(|store| store.0.clone())
186 }
187
188 pub fn global(cx: &App) -> Entity<Self> {
189 cx.global::<GlobalThreadMetadataStore>().0.clone()
190 }
191
192 pub fn is_empty(&self) -> bool {
193 self.threads.is_empty()
194 }
195
196 pub fn entries(&self) -> impl Iterator<Item = ThreadMetadata> + '_ {
197 self.threads.iter().cloned()
198 }
199
200 pub fn entry_ids(&self) -> impl Iterator<Item = acp::SessionId> + '_ {
201 self.threads.iter().map(|thread| thread.session_id.clone())
202 }
203
204 pub fn entries_for_path(
205 &self,
206 path_list: &PathList,
207 ) -> impl Iterator<Item = ThreadMetadata> + '_ {
208 self.threads_by_paths
209 .get(path_list)
210 .into_iter()
211 .flatten()
212 .cloned()
213 }
214
215 fn reload(&mut self, cx: &mut Context<Self>) -> Shared<Task<()>> {
216 let db = self.db.clone();
217 self.reload_task.take();
218
219 let list_task = cx
220 .background_spawn(async move { db.list().context("Failed to fetch sidebar metadata") });
221
222 let reload_task = cx
223 .spawn(async move |this, cx| {
224 let Some(rows) = list_task.await.log_err() else {
225 return;
226 };
227
228 this.update(cx, |this, cx| {
229 this.threads.clear();
230 this.threads_by_paths.clear();
231
232 for row in rows {
233 this.threads_by_paths
234 .entry(row.folder_paths.clone())
235 .or_default()
236 .push(row.clone());
237 this.threads.push(row);
238 }
239
240 cx.notify();
241 })
242 .ok();
243 })
244 .shared();
245 self.reload_task = Some(reload_task.clone());
246 reload_task
247 }
248
249 pub fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context<Self>) -> Task<Result<()>> {
250 if !cx.has_flag::<AgentV2FeatureFlag>() {
251 return Task::ready(Ok(()));
252 }
253
254 let db = self.db.clone();
255 cx.spawn(async move |this, cx| {
256 db.save(metadata).await?;
257 let reload_task = this.update(cx, |this, cx| this.reload(cx))?;
258 reload_task.await;
259 Ok(())
260 })
261 }
262
263 pub fn delete(
264 &mut self,
265 session_id: acp::SessionId,
266 cx: &mut Context<Self>,
267 ) -> Task<Result<()>> {
268 if !cx.has_flag::<AgentV2FeatureFlag>() {
269 return Task::ready(Ok(()));
270 }
271
272 let db = self.db.clone();
273 cx.spawn(async move |this, cx| {
274 db.delete(session_id).await?;
275 let reload_task = this.update(cx, |this, cx| this.reload(cx))?;
276 reload_task.await;
277 Ok(())
278 })
279 }
280
281 fn new(db: ThreadMetadataDb, cx: &mut Context<Self>) -> Self {
282 let weak_store = cx.weak_entity();
283
284 cx.observe_new::<acp_thread::AcpThread>(move |thread, _window, cx| {
285 // Don't track subagent threads in the sidebar.
286 if thread.parent_session_id().is_some() {
287 return;
288 }
289
290 let thread_entity = cx.entity();
291
292 cx.on_release({
293 let weak_store = weak_store.clone();
294 move |thread, cx| {
295 weak_store
296 .update(cx, |store, _cx| {
297 store.session_subscriptions.remove(thread.session_id());
298 })
299 .ok();
300 }
301 })
302 .detach();
303
304 weak_store
305 .update(cx, |this, cx| {
306 let subscription = cx.subscribe(&thread_entity, Self::handle_thread_update);
307 this.session_subscriptions
308 .insert(thread.session_id().clone(), subscription);
309 })
310 .ok();
311 })
312 .detach();
313
314 let mut this = Self {
315 db,
316 threads: Vec::new(),
317 threads_by_paths: HashMap::default(),
318 reload_task: None,
319 session_subscriptions: HashMap::default(),
320 };
321 let _ = this.reload(cx);
322 this
323 }
324
325 fn handle_thread_update(
326 &mut self,
327 thread: Entity<acp_thread::AcpThread>,
328 event: &acp_thread::AcpThreadEvent,
329 cx: &mut Context<Self>,
330 ) {
331 // Don't track subagent threads in the sidebar.
332 if thread.read(cx).parent_session_id().is_some() {
333 return;
334 }
335
336 match event {
337 acp_thread::AcpThreadEvent::NewEntry
338 | acp_thread::AcpThreadEvent::EntryUpdated(_)
339 | acp_thread::AcpThreadEvent::TitleUpdated => {
340 let metadata = ThreadMetadata::from_thread(&thread, cx);
341 self.save(metadata, cx).detach_and_log_err(cx);
342 }
343 _ => {}
344 }
345 }
346}
347
348impl Global for SidebarThreadMetadataStore {}
349
350struct ThreadMetadataDb(ThreadSafeConnection);
351
352impl Domain for ThreadMetadataDb {
353 const NAME: &str = stringify!(ThreadMetadataDb);
354
355 const MIGRATIONS: &[&str] = &[sql!(
356 CREATE TABLE IF NOT EXISTS sidebar_threads(
357 session_id TEXT PRIMARY KEY,
358 agent_id TEXT,
359 title TEXT NOT NULL,
360 updated_at TEXT NOT NULL,
361 created_at TEXT,
362 folder_paths TEXT,
363 folder_paths_order TEXT
364 ) STRICT;
365 )];
366}
367
368db::static_connection!(ThreadMetadataDb, []);
369
370impl ThreadMetadataDb {
371 pub fn is_empty(&self) -> anyhow::Result<bool> {
372 self.select::<i64>("SELECT COUNT(*) FROM sidebar_threads")?()
373 .map(|counts| counts.into_iter().next().unwrap_or_default() == 0)
374 }
375
376 /// List all sidebar thread metadata, ordered by updated_at descending.
377 pub fn list(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
378 self.select::<ThreadMetadata>(
379 "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order \
380 FROM sidebar_threads \
381 ORDER BY updated_at DESC"
382 )?()
383 }
384
385 /// Upsert metadata for a thread.
386 pub async fn save(&self, row: ThreadMetadata) -> anyhow::Result<()> {
387 let id = row.session_id.0.clone();
388 let agent_id = row.agent_id.as_ref().map(|id| id.0.to_string());
389 let title = row.title.to_string();
390 let updated_at = row.updated_at.to_rfc3339();
391 let created_at = row.created_at.map(|dt| dt.to_rfc3339());
392 let serialized = row.folder_paths.serialize();
393 let (folder_paths, folder_paths_order) = if row.folder_paths.is_empty() {
394 (None, None)
395 } else {
396 (Some(serialized.paths), Some(serialized.order))
397 };
398
399 self.write(move |conn| {
400 let sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order) \
401 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
402 ON CONFLICT(session_id) DO UPDATE SET \
403 agent_id = excluded.agent_id, \
404 title = excluded.title, \
405 updated_at = excluded.updated_at, \
406 folder_paths = excluded.folder_paths, \
407 folder_paths_order = excluded.folder_paths_order";
408 let mut stmt = Statement::prepare(conn, sql)?;
409 let mut i = stmt.bind(&id, 1)?;
410 i = stmt.bind(&agent_id, i)?;
411 i = stmt.bind(&title, i)?;
412 i = stmt.bind(&updated_at, i)?;
413 i = stmt.bind(&created_at, i)?;
414 i = stmt.bind(&folder_paths, i)?;
415 stmt.bind(&folder_paths_order, i)?;
416 stmt.exec()
417 })
418 .await
419 }
420
421 /// Delete metadata for a single thread.
422 pub async fn delete(&self, session_id: acp::SessionId) -> anyhow::Result<()> {
423 let id = session_id.0.clone();
424 self.write(move |conn| {
425 let mut stmt =
426 Statement::prepare(conn, "DELETE FROM sidebar_threads WHERE session_id = ?")?;
427 stmt.bind(&id, 1)?;
428 stmt.exec()
429 })
430 .await
431 }
432}
433
434impl Column for ThreadMetadata {
435 fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
436 let (id, next): (Arc<str>, i32) = Column::column(statement, start_index)?;
437 let (agent_id, next): (Option<String>, i32) = Column::column(statement, next)?;
438 let (title, next): (String, i32) = Column::column(statement, next)?;
439 let (updated_at_str, next): (String, i32) = Column::column(statement, next)?;
440 let (created_at_str, next): (Option<String>, i32) = Column::column(statement, next)?;
441 let (folder_paths_str, next): (Option<String>, i32) = Column::column(statement, next)?;
442 let (folder_paths_order_str, next): (Option<String>, i32) =
443 Column::column(statement, next)?;
444
445 let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc);
446 let created_at = created_at_str
447 .as_deref()
448 .map(DateTime::parse_from_rfc3339)
449 .transpose()?
450 .map(|dt| dt.with_timezone(&Utc));
451
452 let folder_paths = folder_paths_str
453 .map(|paths| {
454 PathList::deserialize(&util::path_list::SerializedPathList {
455 paths,
456 order: folder_paths_order_str.unwrap_or_default(),
457 })
458 })
459 .unwrap_or_default();
460
461 Ok((
462 ThreadMetadata {
463 session_id: acp::SessionId::new(id),
464 agent_id: agent_id.map(|id| AgentId::new(id)),
465 title: title.into(),
466 updated_at,
467 created_at,
468 folder_paths,
469 },
470 next,
471 ))
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use acp_thread::{AgentConnection, StubAgentConnection};
479 use action_log::ActionLog;
480 use agent::DbThread;
481 use agent_client_protocol as acp;
482 use feature_flags::FeatureFlagAppExt;
483 use gpui::TestAppContext;
484 use project::FakeFs;
485 use project::Project;
486 use std::path::Path;
487 use std::rc::Rc;
488
489 fn make_db_thread(title: &str, updated_at: DateTime<Utc>) -> DbThread {
490 DbThread {
491 title: title.to_string().into(),
492 messages: Vec::new(),
493 updated_at,
494 detailed_summary: None,
495 initial_project_snapshot: None,
496 cumulative_token_usage: Default::default(),
497 request_token_usage: Default::default(),
498 model: None,
499 profile: None,
500 imported: false,
501 subagent_context: None,
502 speed: None,
503 thinking_enabled: false,
504 thinking_effort: None,
505 draft_prompt: None,
506 ui_scroll_position: None,
507 }
508 }
509
510 fn make_metadata(
511 session_id: &str,
512 title: &str,
513 updated_at: DateTime<Utc>,
514 folder_paths: PathList,
515 ) -> ThreadMetadata {
516 ThreadMetadata {
517 session_id: acp::SessionId::new(session_id),
518 agent_id: None,
519 title: title.to_string().into(),
520 updated_at,
521 created_at: Some(updated_at),
522 folder_paths,
523 }
524 }
525
526 #[gpui::test]
527 async fn test_store_initializes_cache_from_database(cx: &mut TestAppContext) {
528 let first_paths = PathList::new(&[Path::new("/project-a")]);
529 let second_paths = PathList::new(&[Path::new("/project-b")]);
530 let now = Utc::now();
531 let older = now - chrono::Duration::seconds(1);
532
533 let thread = std::thread::current();
534 let test_name = thread.name().unwrap_or("unknown_test");
535 let db_name = format!("THREAD_METADATA_DB_{}", test_name);
536 let db = ThreadMetadataDb(smol::block_on(db::open_test_db::<ThreadMetadataDb>(
537 &db_name,
538 )));
539
540 db.save(make_metadata(
541 "session-1",
542 "First Thread",
543 now,
544 first_paths.clone(),
545 ))
546 .await
547 .unwrap();
548 db.save(make_metadata(
549 "session-2",
550 "Second Thread",
551 older,
552 second_paths.clone(),
553 ))
554 .await
555 .unwrap();
556
557 cx.update(|cx| {
558 let settings_store = settings::SettingsStore::test(cx);
559 cx.set_global(settings_store);
560 cx.update_flags(true, vec!["agent-v2".to_string()]);
561 SidebarThreadMetadataStore::init_global(cx);
562 });
563
564 cx.run_until_parked();
565
566 cx.update(|cx| {
567 let store = SidebarThreadMetadataStore::global(cx);
568 let store = store.read(cx);
569
570 let entry_ids = store
571 .entry_ids()
572 .map(|session_id| session_id.0.to_string())
573 .collect::<Vec<_>>();
574 assert_eq!(entry_ids, vec!["session-1", "session-2"]);
575
576 let first_path_entries = store
577 .entries_for_path(&first_paths)
578 .map(|entry| entry.session_id.0.to_string())
579 .collect::<Vec<_>>();
580 assert_eq!(first_path_entries, vec!["session-1"]);
581
582 let second_path_entries = store
583 .entries_for_path(&second_paths)
584 .map(|entry| entry.session_id.0.to_string())
585 .collect::<Vec<_>>();
586 assert_eq!(second_path_entries, vec!["session-2"]);
587 });
588 }
589
590 #[gpui::test]
591 async fn test_store_cache_updates_after_save_and_delete(cx: &mut TestAppContext) {
592 cx.update(|cx| {
593 let settings_store = settings::SettingsStore::test(cx);
594 cx.set_global(settings_store);
595 cx.update_flags(true, vec!["agent-v2".to_string()]);
596 SidebarThreadMetadataStore::init_global(cx);
597 });
598
599 let first_paths = PathList::new(&[Path::new("/project-a")]);
600 let second_paths = PathList::new(&[Path::new("/project-b")]);
601 let initial_time = Utc::now();
602 let updated_time = initial_time + chrono::Duration::seconds(1);
603
604 let initial_metadata = make_metadata(
605 "session-1",
606 "First Thread",
607 initial_time,
608 first_paths.clone(),
609 );
610
611 let second_metadata = make_metadata(
612 "session-2",
613 "Second Thread",
614 initial_time,
615 second_paths.clone(),
616 );
617
618 cx.update(|cx| {
619 let store = SidebarThreadMetadataStore::global(cx);
620 store.update(cx, |store, cx| {
621 store.save(initial_metadata, cx).detach();
622 store.save(second_metadata, cx).detach();
623 });
624 });
625
626 cx.run_until_parked();
627
628 cx.update(|cx| {
629 let store = SidebarThreadMetadataStore::global(cx);
630 let store = store.read(cx);
631
632 let first_path_entries = store
633 .entries_for_path(&first_paths)
634 .map(|entry| entry.session_id.0.to_string())
635 .collect::<Vec<_>>();
636 assert_eq!(first_path_entries, vec!["session-1"]);
637
638 let second_path_entries = store
639 .entries_for_path(&second_paths)
640 .map(|entry| entry.session_id.0.to_string())
641 .collect::<Vec<_>>();
642 assert_eq!(second_path_entries, vec!["session-2"]);
643 });
644
645 let moved_metadata = make_metadata(
646 "session-1",
647 "First Thread",
648 updated_time,
649 second_paths.clone(),
650 );
651
652 cx.update(|cx| {
653 let store = SidebarThreadMetadataStore::global(cx);
654 store.update(cx, |store, cx| {
655 store.save(moved_metadata, cx).detach();
656 });
657 });
658
659 cx.run_until_parked();
660
661 cx.update(|cx| {
662 let store = SidebarThreadMetadataStore::global(cx);
663 let store = store.read(cx);
664
665 let entry_ids = store
666 .entry_ids()
667 .map(|session_id| session_id.0.to_string())
668 .collect::<Vec<_>>();
669 assert_eq!(entry_ids, vec!["session-1", "session-2"]);
670
671 let first_path_entries = store
672 .entries_for_path(&first_paths)
673 .map(|entry| entry.session_id.0.to_string())
674 .collect::<Vec<_>>();
675 assert!(first_path_entries.is_empty());
676
677 let second_path_entries = store
678 .entries_for_path(&second_paths)
679 .map(|entry| entry.session_id.0.to_string())
680 .collect::<Vec<_>>();
681 assert_eq!(second_path_entries, vec!["session-1", "session-2"]);
682 });
683
684 cx.update(|cx| {
685 let store = SidebarThreadMetadataStore::global(cx);
686 store.update(cx, |store, cx| {
687 store.delete(acp::SessionId::new("session-2"), cx).detach();
688 });
689 });
690
691 cx.run_until_parked();
692
693 cx.update(|cx| {
694 let store = SidebarThreadMetadataStore::global(cx);
695 let store = store.read(cx);
696
697 let entry_ids = store
698 .entry_ids()
699 .map(|session_id| session_id.0.to_string())
700 .collect::<Vec<_>>();
701 assert_eq!(entry_ids, vec!["session-1"]);
702
703 let second_path_entries = store
704 .entries_for_path(&second_paths)
705 .map(|entry| entry.session_id.0.to_string())
706 .collect::<Vec<_>>();
707 assert_eq!(second_path_entries, vec!["session-1"]);
708 });
709 }
710
711 #[gpui::test]
712 async fn test_migrate_thread_metadata(cx: &mut TestAppContext) {
713 cx.update(|cx| {
714 ThreadStore::init_global(cx);
715 SidebarThreadMetadataStore::init_global(cx);
716 });
717
718 // Verify the cache is empty before migration
719 let list = cx.update(|cx| {
720 let store = SidebarThreadMetadataStore::global(cx);
721 store.read(cx).entries().collect::<Vec<_>>()
722 });
723 assert_eq!(list.len(), 0);
724
725 let now = Utc::now();
726
727 // Populate the native ThreadStore via save_thread
728 let save1 = cx.update(|cx| {
729 let thread_store = ThreadStore::global(cx);
730 thread_store.update(cx, |store, cx| {
731 store.save_thread(
732 acp::SessionId::new("session-1"),
733 make_db_thread("Thread 1", now),
734 PathList::default(),
735 cx,
736 )
737 })
738 });
739 save1.await.unwrap();
740 cx.run_until_parked();
741
742 let save2 = cx.update(|cx| {
743 let thread_store = ThreadStore::global(cx);
744 thread_store.update(cx, |store, cx| {
745 store.save_thread(
746 acp::SessionId::new("session-2"),
747 make_db_thread("Thread 2", now),
748 PathList::default(),
749 cx,
750 )
751 })
752 });
753 save2.await.unwrap();
754 cx.run_until_parked();
755
756 // Run migration
757 cx.update(|cx| {
758 migrate_thread_metadata(cx);
759 });
760
761 cx.run_until_parked();
762
763 // Verify the metadata was migrated
764 let list = cx.update(|cx| {
765 let store = SidebarThreadMetadataStore::global(cx);
766 store.read(cx).entries().collect::<Vec<_>>()
767 });
768 assert_eq!(list.len(), 2);
769
770 let metadata1 = list
771 .iter()
772 .find(|m| m.session_id.0.as_ref() == "session-1")
773 .expect("session-1 should be in migrated metadata");
774 assert_eq!(metadata1.title.as_ref(), "Thread 1");
775 assert!(metadata1.agent_id.is_none());
776
777 let metadata2 = list
778 .iter()
779 .find(|m| m.session_id.0.as_ref() == "session-2")
780 .expect("session-2 should be in migrated metadata");
781 assert_eq!(metadata2.title.as_ref(), "Thread 2");
782 assert!(metadata2.agent_id.is_none());
783 }
784
785 #[gpui::test]
786 async fn test_migrate_thread_metadata_skips_when_data_exists(cx: &mut TestAppContext) {
787 cx.update(|cx| {
788 ThreadStore::init_global(cx);
789 SidebarThreadMetadataStore::init_global(cx);
790 });
791
792 // Pre-populate the metadata store with existing data
793 let existing_metadata = ThreadMetadata {
794 session_id: acp::SessionId::new("existing-session"),
795 agent_id: None,
796 title: "Existing Thread".into(),
797 updated_at: Utc::now(),
798 created_at: Some(Utc::now()),
799 folder_paths: PathList::default(),
800 };
801
802 cx.update(|cx| {
803 let store = SidebarThreadMetadataStore::global(cx);
804 store.update(cx, |store, cx| {
805 store.save(existing_metadata, cx).detach();
806 });
807 });
808
809 cx.run_until_parked();
810
811 // Add an entry to native thread store that should NOT be migrated
812 let save_task = cx.update(|cx| {
813 let thread_store = ThreadStore::global(cx);
814 thread_store.update(cx, |store, cx| {
815 store.save_thread(
816 acp::SessionId::new("native-session"),
817 make_db_thread("Native Thread", Utc::now()),
818 PathList::default(),
819 cx,
820 )
821 })
822 });
823 save_task.await.unwrap();
824 cx.run_until_parked();
825
826 // Run migration - should skip because metadata store is not empty
827 cx.update(|cx| {
828 migrate_thread_metadata(cx);
829 });
830
831 cx.run_until_parked();
832
833 // Verify only the existing metadata is present (migration was skipped)
834 let list = cx.update(|cx| {
835 let store = SidebarThreadMetadataStore::global(cx);
836 store.read(cx).entries().collect::<Vec<_>>()
837 });
838 assert_eq!(list.len(), 1);
839 assert_eq!(list[0].session_id.0.as_ref(), "existing-session");
840 }
841
842 #[gpui::test]
843 async fn test_subagent_threads_excluded_from_sidebar_metadata(cx: &mut TestAppContext) {
844 cx.update(|cx| {
845 let settings_store = settings::SettingsStore::test(cx);
846 cx.set_global(settings_store);
847 cx.update_flags(true, vec!["agent-v2".to_string()]);
848 ThreadStore::init_global(cx);
849 SidebarThreadMetadataStore::init_global(cx);
850 });
851
852 let fs = FakeFs::new(cx.executor());
853 let project = Project::test(fs, None::<&Path>, cx).await;
854 let connection = Rc::new(StubAgentConnection::new());
855
856 // Create a regular (non-subagent) AcpThread.
857 let regular_thread = cx
858 .update(|cx| {
859 connection
860 .clone()
861 .new_session(project.clone(), PathList::default(), cx)
862 })
863 .await
864 .unwrap();
865
866 let regular_session_id = cx.read(|cx| regular_thread.read(cx).session_id().clone());
867
868 // Set a title on the regular thread to trigger a save via handle_thread_update.
869 cx.update(|cx| {
870 regular_thread.update(cx, |thread, cx| {
871 thread.set_title("Regular Thread".into(), cx).detach();
872 });
873 });
874 cx.run_until_parked();
875
876 // Create a subagent AcpThread
877 let subagent_session_id = acp::SessionId::new("subagent-session");
878 let subagent_thread = cx.update(|cx| {
879 let action_log = cx.new(|_| ActionLog::new(project.clone()));
880 cx.new(|cx| {
881 acp_thread::AcpThread::new(
882 Some(regular_session_id.clone()),
883 "Subagent Thread",
884 None,
885 connection.clone(),
886 project.clone(),
887 action_log,
888 subagent_session_id.clone(),
889 watch::Receiver::constant(acp::PromptCapabilities::new()),
890 cx,
891 )
892 })
893 });
894
895 // Set a title on the subagent thread to trigger handle_thread_update.
896 cx.update(|cx| {
897 subagent_thread.update(cx, |thread, cx| {
898 thread
899 .set_title("Subagent Thread Title".into(), cx)
900 .detach();
901 });
902 });
903 cx.run_until_parked();
904
905 // List all metadata from the store cache.
906 let list = cx.update(|cx| {
907 let store = SidebarThreadMetadataStore::global(cx);
908 store.read(cx).entries().collect::<Vec<_>>()
909 });
910
911 // The subagent thread should NOT appear in the sidebar metadata.
912 // Only the regular thread should be listed.
913 assert_eq!(
914 list.len(),
915 1,
916 "Expected only the regular thread in sidebar metadata, \
917 but found {} entries (subagent threads are leaking into the sidebar)",
918 list.len(),
919 );
920 assert_eq!(list[0].session_id, regular_session_id);
921 assert_eq!(list[0].title.as_ref(), "Regular Thread");
922 }
923}