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::TitleUpdated
339 | acp_thread::AcpThreadEvent::EntryUpdated(_)
340 | acp_thread::AcpThreadEvent::EntriesRemoved(_)
341 | acp_thread::AcpThreadEvent::ToolAuthorizationRequested(_)
342 | acp_thread::AcpThreadEvent::ToolAuthorizationReceived(_)
343 | acp_thread::AcpThreadEvent::Retry(_)
344 | acp_thread::AcpThreadEvent::Stopped(_)
345 | acp_thread::AcpThreadEvent::Error
346 | acp_thread::AcpThreadEvent::LoadError(_)
347 | acp_thread::AcpThreadEvent::Refusal => {
348 let metadata = ThreadMetadata::from_thread(&thread, cx);
349 self.save(metadata, cx).detach_and_log_err(cx);
350 }
351 _ => {}
352 }
353 }
354}
355
356impl Global for SidebarThreadMetadataStore {}
357
358struct ThreadMetadataDb(ThreadSafeConnection);
359
360impl Domain for ThreadMetadataDb {
361 const NAME: &str = stringify!(ThreadMetadataDb);
362
363 const MIGRATIONS: &[&str] = &[sql!(
364 CREATE TABLE IF NOT EXISTS sidebar_threads(
365 session_id TEXT PRIMARY KEY,
366 agent_id TEXT,
367 title TEXT NOT NULL,
368 updated_at TEXT NOT NULL,
369 created_at TEXT,
370 folder_paths TEXT,
371 folder_paths_order TEXT
372 ) STRICT;
373 )];
374}
375
376db::static_connection!(ThreadMetadataDb, []);
377
378impl ThreadMetadataDb {
379 pub fn is_empty(&self) -> anyhow::Result<bool> {
380 self.select::<i64>("SELECT COUNT(*) FROM sidebar_threads")?()
381 .map(|counts| counts.into_iter().next().unwrap_or_default() == 0)
382 }
383
384 /// List all sidebar thread metadata, ordered by updated_at descending.
385 pub fn list(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
386 self.select::<ThreadMetadata>(
387 "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order \
388 FROM sidebar_threads \
389 ORDER BY updated_at DESC"
390 )?()
391 }
392
393 /// Upsert metadata for a thread.
394 pub async fn save(&self, row: ThreadMetadata) -> anyhow::Result<()> {
395 let id = row.session_id.0.clone();
396 let agent_id = row.agent_id.as_ref().map(|id| id.0.to_string());
397 let title = row.title.to_string();
398 let updated_at = row.updated_at.to_rfc3339();
399 let created_at = row.created_at.map(|dt| dt.to_rfc3339());
400 let serialized = row.folder_paths.serialize();
401 let (folder_paths, folder_paths_order) = if row.folder_paths.is_empty() {
402 (None, None)
403 } else {
404 (Some(serialized.paths), Some(serialized.order))
405 };
406
407 self.write(move |conn| {
408 let sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order) \
409 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
410 ON CONFLICT(session_id) DO UPDATE SET \
411 agent_id = excluded.agent_id, \
412 title = excluded.title, \
413 updated_at = excluded.updated_at, \
414 folder_paths = excluded.folder_paths, \
415 folder_paths_order = excluded.folder_paths_order";
416 let mut stmt = Statement::prepare(conn, sql)?;
417 let mut i = stmt.bind(&id, 1)?;
418 i = stmt.bind(&agent_id, i)?;
419 i = stmt.bind(&title, i)?;
420 i = stmt.bind(&updated_at, i)?;
421 i = stmt.bind(&created_at, i)?;
422 i = stmt.bind(&folder_paths, i)?;
423 stmt.bind(&folder_paths_order, i)?;
424 stmt.exec()
425 })
426 .await
427 }
428
429 /// Delete metadata for a single thread.
430 pub async fn delete(&self, session_id: acp::SessionId) -> anyhow::Result<()> {
431 let id = session_id.0.clone();
432 self.write(move |conn| {
433 let mut stmt =
434 Statement::prepare(conn, "DELETE FROM sidebar_threads WHERE session_id = ?")?;
435 stmt.bind(&id, 1)?;
436 stmt.exec()
437 })
438 .await
439 }
440}
441
442impl Column for ThreadMetadata {
443 fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
444 let (id, next): (Arc<str>, i32) = Column::column(statement, start_index)?;
445 let (agent_id, next): (Option<String>, i32) = Column::column(statement, next)?;
446 let (title, next): (String, i32) = Column::column(statement, next)?;
447 let (updated_at_str, next): (String, i32) = Column::column(statement, next)?;
448 let (created_at_str, next): (Option<String>, i32) = Column::column(statement, next)?;
449 let (folder_paths_str, next): (Option<String>, i32) = Column::column(statement, next)?;
450 let (folder_paths_order_str, next): (Option<String>, i32) =
451 Column::column(statement, next)?;
452
453 let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc);
454 let created_at = created_at_str
455 .as_deref()
456 .map(DateTime::parse_from_rfc3339)
457 .transpose()?
458 .map(|dt| dt.with_timezone(&Utc));
459
460 let folder_paths = folder_paths_str
461 .map(|paths| {
462 PathList::deserialize(&util::path_list::SerializedPathList {
463 paths,
464 order: folder_paths_order_str.unwrap_or_default(),
465 })
466 })
467 .unwrap_or_default();
468
469 Ok((
470 ThreadMetadata {
471 session_id: acp::SessionId::new(id),
472 agent_id: agent_id.map(|id| AgentId::new(id)),
473 title: title.into(),
474 updated_at,
475 created_at,
476 folder_paths,
477 },
478 next,
479 ))
480 }
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486 use acp_thread::{AgentConnection, StubAgentConnection};
487 use action_log::ActionLog;
488 use agent::DbThread;
489 use agent_client_protocol as acp;
490 use feature_flags::FeatureFlagAppExt;
491 use gpui::TestAppContext;
492 use project::FakeFs;
493 use project::Project;
494 use std::path::Path;
495 use std::rc::Rc;
496
497 fn make_db_thread(title: &str, updated_at: DateTime<Utc>) -> DbThread {
498 DbThread {
499 title: title.to_string().into(),
500 messages: Vec::new(),
501 updated_at,
502 detailed_summary: None,
503 initial_project_snapshot: None,
504 cumulative_token_usage: Default::default(),
505 request_token_usage: Default::default(),
506 model: None,
507 profile: None,
508 imported: false,
509 subagent_context: None,
510 speed: None,
511 thinking_enabled: false,
512 thinking_effort: None,
513 draft_prompt: None,
514 ui_scroll_position: None,
515 }
516 }
517
518 fn make_metadata(
519 session_id: &str,
520 title: &str,
521 updated_at: DateTime<Utc>,
522 folder_paths: PathList,
523 ) -> ThreadMetadata {
524 ThreadMetadata {
525 session_id: acp::SessionId::new(session_id),
526 agent_id: None,
527 title: title.to_string().into(),
528 updated_at,
529 created_at: Some(updated_at),
530 folder_paths,
531 }
532 }
533
534 #[gpui::test]
535 async fn test_store_initializes_cache_from_database(cx: &mut TestAppContext) {
536 let first_paths = PathList::new(&[Path::new("/project-a")]);
537 let second_paths = PathList::new(&[Path::new("/project-b")]);
538 let now = Utc::now();
539 let older = now - chrono::Duration::seconds(1);
540
541 let thread = std::thread::current();
542 let test_name = thread.name().unwrap_or("unknown_test");
543 let db_name = format!("THREAD_METADATA_DB_{}", test_name);
544 let db = ThreadMetadataDb(smol::block_on(db::open_test_db::<ThreadMetadataDb>(
545 &db_name,
546 )));
547
548 db.save(make_metadata(
549 "session-1",
550 "First Thread",
551 now,
552 first_paths.clone(),
553 ))
554 .await
555 .unwrap();
556 db.save(make_metadata(
557 "session-2",
558 "Second Thread",
559 older,
560 second_paths.clone(),
561 ))
562 .await
563 .unwrap();
564
565 cx.update(|cx| {
566 let settings_store = settings::SettingsStore::test(cx);
567 cx.set_global(settings_store);
568 cx.update_flags(true, vec!["agent-v2".to_string()]);
569 SidebarThreadMetadataStore::init_global(cx);
570 });
571
572 cx.run_until_parked();
573
574 cx.update(|cx| {
575 let store = SidebarThreadMetadataStore::global(cx);
576 let store = store.read(cx);
577
578 let entry_ids = store
579 .entry_ids()
580 .map(|session_id| session_id.0.to_string())
581 .collect::<Vec<_>>();
582 assert_eq!(entry_ids, vec!["session-1", "session-2"]);
583
584 let first_path_entries = store
585 .entries_for_path(&first_paths)
586 .map(|entry| entry.session_id.0.to_string())
587 .collect::<Vec<_>>();
588 assert_eq!(first_path_entries, vec!["session-1"]);
589
590 let second_path_entries = store
591 .entries_for_path(&second_paths)
592 .map(|entry| entry.session_id.0.to_string())
593 .collect::<Vec<_>>();
594 assert_eq!(second_path_entries, vec!["session-2"]);
595 });
596 }
597
598 #[gpui::test]
599 async fn test_store_cache_updates_after_save_and_delete(cx: &mut TestAppContext) {
600 cx.update(|cx| {
601 let settings_store = settings::SettingsStore::test(cx);
602 cx.set_global(settings_store);
603 cx.update_flags(true, vec!["agent-v2".to_string()]);
604 SidebarThreadMetadataStore::init_global(cx);
605 });
606
607 let first_paths = PathList::new(&[Path::new("/project-a")]);
608 let second_paths = PathList::new(&[Path::new("/project-b")]);
609 let initial_time = Utc::now();
610 let updated_time = initial_time + chrono::Duration::seconds(1);
611
612 let initial_metadata = make_metadata(
613 "session-1",
614 "First Thread",
615 initial_time,
616 first_paths.clone(),
617 );
618
619 let second_metadata = make_metadata(
620 "session-2",
621 "Second Thread",
622 initial_time,
623 second_paths.clone(),
624 );
625
626 cx.update(|cx| {
627 let store = SidebarThreadMetadataStore::global(cx);
628 store.update(cx, |store, cx| {
629 store.save(initial_metadata, cx).detach();
630 store.save(second_metadata, cx).detach();
631 });
632 });
633
634 cx.run_until_parked();
635
636 cx.update(|cx| {
637 let store = SidebarThreadMetadataStore::global(cx);
638 let store = store.read(cx);
639
640 let first_path_entries = store
641 .entries_for_path(&first_paths)
642 .map(|entry| entry.session_id.0.to_string())
643 .collect::<Vec<_>>();
644 assert_eq!(first_path_entries, vec!["session-1"]);
645
646 let second_path_entries = store
647 .entries_for_path(&second_paths)
648 .map(|entry| entry.session_id.0.to_string())
649 .collect::<Vec<_>>();
650 assert_eq!(second_path_entries, vec!["session-2"]);
651 });
652
653 let moved_metadata = make_metadata(
654 "session-1",
655 "First Thread",
656 updated_time,
657 second_paths.clone(),
658 );
659
660 cx.update(|cx| {
661 let store = SidebarThreadMetadataStore::global(cx);
662 store.update(cx, |store, cx| {
663 store.save(moved_metadata, cx).detach();
664 });
665 });
666
667 cx.run_until_parked();
668
669 cx.update(|cx| {
670 let store = SidebarThreadMetadataStore::global(cx);
671 let store = store.read(cx);
672
673 let entry_ids = store
674 .entry_ids()
675 .map(|session_id| session_id.0.to_string())
676 .collect::<Vec<_>>();
677 assert_eq!(entry_ids, vec!["session-1", "session-2"]);
678
679 let first_path_entries = store
680 .entries_for_path(&first_paths)
681 .map(|entry| entry.session_id.0.to_string())
682 .collect::<Vec<_>>();
683 assert!(first_path_entries.is_empty());
684
685 let second_path_entries = store
686 .entries_for_path(&second_paths)
687 .map(|entry| entry.session_id.0.to_string())
688 .collect::<Vec<_>>();
689 assert_eq!(second_path_entries, vec!["session-1", "session-2"]);
690 });
691
692 cx.update(|cx| {
693 let store = SidebarThreadMetadataStore::global(cx);
694 store.update(cx, |store, cx| {
695 store.delete(acp::SessionId::new("session-2"), cx).detach();
696 });
697 });
698
699 cx.run_until_parked();
700
701 cx.update(|cx| {
702 let store = SidebarThreadMetadataStore::global(cx);
703 let store = store.read(cx);
704
705 let entry_ids = store
706 .entry_ids()
707 .map(|session_id| session_id.0.to_string())
708 .collect::<Vec<_>>();
709 assert_eq!(entry_ids, vec!["session-1"]);
710
711 let second_path_entries = store
712 .entries_for_path(&second_paths)
713 .map(|entry| entry.session_id.0.to_string())
714 .collect::<Vec<_>>();
715 assert_eq!(second_path_entries, vec!["session-1"]);
716 });
717 }
718
719 #[gpui::test]
720 async fn test_migrate_thread_metadata(cx: &mut TestAppContext) {
721 cx.update(|cx| {
722 ThreadStore::init_global(cx);
723 SidebarThreadMetadataStore::init_global(cx);
724 });
725
726 // Verify the cache is empty before migration
727 let list = cx.update(|cx| {
728 let store = SidebarThreadMetadataStore::global(cx);
729 store.read(cx).entries().collect::<Vec<_>>()
730 });
731 assert_eq!(list.len(), 0);
732
733 let now = Utc::now();
734
735 // Populate the native ThreadStore via save_thread
736 let save1 = cx.update(|cx| {
737 let thread_store = ThreadStore::global(cx);
738 thread_store.update(cx, |store, cx| {
739 store.save_thread(
740 acp::SessionId::new("session-1"),
741 make_db_thread("Thread 1", now),
742 PathList::default(),
743 cx,
744 )
745 })
746 });
747 save1.await.unwrap();
748 cx.run_until_parked();
749
750 let save2 = cx.update(|cx| {
751 let thread_store = ThreadStore::global(cx);
752 thread_store.update(cx, |store, cx| {
753 store.save_thread(
754 acp::SessionId::new("session-2"),
755 make_db_thread("Thread 2", now),
756 PathList::default(),
757 cx,
758 )
759 })
760 });
761 save2.await.unwrap();
762 cx.run_until_parked();
763
764 // Run migration
765 cx.update(|cx| {
766 migrate_thread_metadata(cx);
767 });
768
769 cx.run_until_parked();
770
771 // Verify the metadata was migrated
772 let list = cx.update(|cx| {
773 let store = SidebarThreadMetadataStore::global(cx);
774 store.read(cx).entries().collect::<Vec<_>>()
775 });
776 assert_eq!(list.len(), 2);
777
778 let metadata1 = list
779 .iter()
780 .find(|m| m.session_id.0.as_ref() == "session-1")
781 .expect("session-1 should be in migrated metadata");
782 assert_eq!(metadata1.title.as_ref(), "Thread 1");
783 assert!(metadata1.agent_id.is_none());
784
785 let metadata2 = list
786 .iter()
787 .find(|m| m.session_id.0.as_ref() == "session-2")
788 .expect("session-2 should be in migrated metadata");
789 assert_eq!(metadata2.title.as_ref(), "Thread 2");
790 assert!(metadata2.agent_id.is_none());
791 }
792
793 #[gpui::test]
794 async fn test_migrate_thread_metadata_skips_when_data_exists(cx: &mut TestAppContext) {
795 cx.update(|cx| {
796 ThreadStore::init_global(cx);
797 SidebarThreadMetadataStore::init_global(cx);
798 });
799
800 // Pre-populate the metadata store with existing data
801 let existing_metadata = ThreadMetadata {
802 session_id: acp::SessionId::new("existing-session"),
803 agent_id: None,
804 title: "Existing Thread".into(),
805 updated_at: Utc::now(),
806 created_at: Some(Utc::now()),
807 folder_paths: PathList::default(),
808 };
809
810 cx.update(|cx| {
811 let store = SidebarThreadMetadataStore::global(cx);
812 store.update(cx, |store, cx| {
813 store.save(existing_metadata, cx).detach();
814 });
815 });
816
817 cx.run_until_parked();
818
819 // Add an entry to native thread store that should NOT be migrated
820 let save_task = cx.update(|cx| {
821 let thread_store = ThreadStore::global(cx);
822 thread_store.update(cx, |store, cx| {
823 store.save_thread(
824 acp::SessionId::new("native-session"),
825 make_db_thread("Native Thread", Utc::now()),
826 PathList::default(),
827 cx,
828 )
829 })
830 });
831 save_task.await.unwrap();
832 cx.run_until_parked();
833
834 // Run migration - should skip because metadata store is not empty
835 cx.update(|cx| {
836 migrate_thread_metadata(cx);
837 });
838
839 cx.run_until_parked();
840
841 // Verify only the existing metadata is present (migration was skipped)
842 let list = cx.update(|cx| {
843 let store = SidebarThreadMetadataStore::global(cx);
844 store.read(cx).entries().collect::<Vec<_>>()
845 });
846 assert_eq!(list.len(), 1);
847 assert_eq!(list[0].session_id.0.as_ref(), "existing-session");
848 }
849
850 #[gpui::test]
851 async fn test_subagent_threads_excluded_from_sidebar_metadata(cx: &mut TestAppContext) {
852 cx.update(|cx| {
853 let settings_store = settings::SettingsStore::test(cx);
854 cx.set_global(settings_store);
855 cx.update_flags(true, vec!["agent-v2".to_string()]);
856 ThreadStore::init_global(cx);
857 SidebarThreadMetadataStore::init_global(cx);
858 });
859
860 let fs = FakeFs::new(cx.executor());
861 let project = Project::test(fs, None::<&Path>, cx).await;
862 let connection = Rc::new(StubAgentConnection::new());
863
864 // Create a regular (non-subagent) AcpThread.
865 let regular_thread = cx
866 .update(|cx| {
867 connection
868 .clone()
869 .new_session(project.clone(), PathList::default(), cx)
870 })
871 .await
872 .unwrap();
873
874 let regular_session_id = cx.read(|cx| regular_thread.read(cx).session_id().clone());
875
876 // Set a title on the regular thread to trigger a save via handle_thread_update.
877 cx.update(|cx| {
878 regular_thread.update(cx, |thread, cx| {
879 thread.set_title("Regular Thread".into(), cx).detach();
880 });
881 });
882 cx.run_until_parked();
883
884 // Create a subagent AcpThread
885 let subagent_session_id = acp::SessionId::new("subagent-session");
886 let subagent_thread = cx.update(|cx| {
887 let action_log = cx.new(|_| ActionLog::new(project.clone()));
888 cx.new(|cx| {
889 acp_thread::AcpThread::new(
890 Some(regular_session_id.clone()),
891 "Subagent Thread",
892 None,
893 connection.clone(),
894 project.clone(),
895 action_log,
896 subagent_session_id.clone(),
897 watch::Receiver::constant(acp::PromptCapabilities::new()),
898 cx,
899 )
900 })
901 });
902
903 // Set a title on the subagent thread to trigger handle_thread_update.
904 cx.update(|cx| {
905 subagent_thread.update(cx, |thread, cx| {
906 thread
907 .set_title("Subagent Thread Title".into(), cx)
908 .detach();
909 });
910 });
911 cx.run_until_parked();
912
913 // List all metadata from the store cache.
914 let list = cx.update(|cx| {
915 let store = SidebarThreadMetadataStore::global(cx);
916 store.read(cx).entries().collect::<Vec<_>>()
917 });
918
919 // The subagent thread should NOT appear in the sidebar metadata.
920 // Only the regular thread should be listed.
921 assert_eq!(
922 list.len(),
923 1,
924 "Expected only the regular thread in sidebar metadata, \
925 but found {} entries (subagent threads are leaking into the sidebar)",
926 list.len(),
927 );
928 assert_eq!(list[0].session_id, regular_session_id);
929 assert_eq!(list[0].title.as_ref(), "Regular Thread");
930 }
931}