thread_metadata_store.rs

   1use std::{
   2    path::{Path, PathBuf},
   3    sync::Arc,
   4};
   5
   6use acp_thread::AcpThreadEvent;
   7use agent::{ThreadStore, ZED_AGENT_ID};
   8use agent_client_protocol as acp;
   9use anyhow::Context as _;
  10use chrono::{DateTime, Utc};
  11use collections::{HashMap, HashSet};
  12use db::{
  13    sqlez::{
  14        bindable::Column, domain::Domain, statement::Statement,
  15        thread_safe_connection::ThreadSafeConnection,
  16    },
  17    sqlez_macros::sql,
  18};
  19use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
  20use futures::{FutureExt as _, future::Shared};
  21use gpui::{AppContext as _, Entity, Global, Subscription, Task};
  22use project::AgentId;
  23use ui::{App, Context, SharedString};
  24use util::ResultExt as _;
  25use workspace::PathList;
  26
  27use crate::DEFAULT_THREAD_TITLE;
  28
  29pub fn init(cx: &mut App) {
  30    ThreadMetadataStore::init_global(cx);
  31
  32    if cx.has_flag::<AgentV2FeatureFlag>() {
  33        migrate_thread_metadata(cx);
  34    }
  35    cx.observe_flag::<AgentV2FeatureFlag, _>(|has_flag, cx| {
  36        if has_flag {
  37            migrate_thread_metadata(cx);
  38        }
  39    })
  40    .detach();
  41}
  42
  43/// Migrate existing thread metadata from native agent thread store to the new metadata storage.
  44/// We skip migrating threads that do not have a project.
  45///
  46/// TODO: Remove this after N weeks of shipping the sidebar
  47fn migrate_thread_metadata(cx: &mut App) {
  48    let store = ThreadMetadataStore::global(cx);
  49    let db = store.read(cx).db.clone();
  50
  51    cx.spawn(async move |cx| {
  52        let existing_entries = db.list_ids()?.into_iter().collect::<HashSet<_>>();
  53
  54        let is_first_migration = existing_entries.is_empty();
  55
  56        let mut to_migrate = store.read_with(cx, |_store, cx| {
  57            ThreadStore::global(cx)
  58                .read(cx)
  59                .entries()
  60                .filter_map(|entry| {
  61                    if existing_entries.contains(&entry.id.0) {
  62                        return None;
  63                    }
  64
  65                    Some(ThreadMetadata {
  66                        session_id: entry.id,
  67                        agent_id: ZED_AGENT_ID.clone(),
  68                        title: entry.title,
  69                        updated_at: entry.updated_at,
  70                        created_at: entry.created_at,
  71                        folder_paths: entry.folder_paths,
  72                        main_worktree_paths: PathList::default(),
  73                        archived: true,
  74                        pending_worktree_restore: None,
  75                    })
  76                })
  77                .collect::<Vec<_>>()
  78        });
  79
  80        if to_migrate.is_empty() {
  81            return anyhow::Ok(());
  82        }
  83
  84        // On the first migration (no entries in DB yet), keep the 5 most
  85        // recent threads per project unarchived.
  86        if is_first_migration {
  87            let mut per_project: HashMap<PathList, Vec<&mut ThreadMetadata>> = HashMap::default();
  88            for entry in &mut to_migrate {
  89                if entry.folder_paths.is_empty() {
  90                    continue;
  91                }
  92                per_project
  93                    .entry(entry.folder_paths.clone())
  94                    .or_default()
  95                    .push(entry);
  96            }
  97            for entries in per_project.values_mut() {
  98                entries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
  99                for entry in entries.iter_mut().take(5) {
 100                    entry.archived = false;
 101                }
 102            }
 103        }
 104
 105        log::info!("Migrating {} thread store entries", to_migrate.len());
 106
 107        // Manually save each entry to the database and call reload, otherwise
 108        // we'll end up triggering lots of reloads after each save
 109        for entry in to_migrate {
 110            db.save(entry).await?;
 111        }
 112
 113        log::info!("Finished migrating thread store entries");
 114
 115        let _ = store.update(cx, |store, cx| store.reload(cx));
 116        anyhow::Ok(())
 117    })
 118    .detach_and_log_err(cx);
 119}
 120
 121struct GlobalThreadMetadataStore(Entity<ThreadMetadataStore>);
 122impl Global for GlobalThreadMetadataStore {}
 123
 124/// Lightweight metadata for any thread (native or ACP), enough to populate
 125/// the sidebar list and route to the correct load path when clicked.
 126#[derive(Debug, Clone, PartialEq)]
 127pub struct ThreadMetadata {
 128    pub session_id: acp::SessionId,
 129    pub agent_id: AgentId,
 130    pub title: SharedString,
 131    pub updated_at: DateTime<Utc>,
 132    pub created_at: Option<DateTime<Utc>>,
 133    pub folder_paths: PathList,
 134    pub main_worktree_paths: PathList,
 135    pub archived: bool,
 136    /// When set, the thread's original worktree is being restored in the background.
 137    /// The PathBuf is the main repo path shown temporarily while restoration is pending.
 138    /// This is runtime-only state — not persisted to the database.
 139    pub pending_worktree_restore: Option<PathBuf>,
 140}
 141
 142impl From<&ThreadMetadata> for acp_thread::AgentSessionInfo {
 143    fn from(meta: &ThreadMetadata) -> Self {
 144        Self {
 145            session_id: meta.session_id.clone(),
 146            work_dirs: Some(meta.folder_paths.clone()),
 147            title: Some(meta.title.clone()),
 148            updated_at: Some(meta.updated_at),
 149            created_at: meta.created_at,
 150            meta: None,
 151        }
 152    }
 153}
 154
 155/// Record of a git worktree that was archived (deleted from disk) when its
 156/// last thread was archived.
 157pub struct ArchivedGitWorktree {
 158    /// Auto-incrementing primary key.
 159    pub id: i64,
 160    /// Absolute path to the worktree directory before deletion.
 161    pub worktree_path: PathBuf,
 162    /// Absolute path of the main repository that owned this worktree.
 163    pub main_repo_path: PathBuf,
 164    /// Branch checked out at archive time. None if detached HEAD.
 165    pub branch_name: Option<String>,
 166    /// SHA of the commit capturing the staged state at archive time.
 167    pub staged_commit_hash: String,
 168    /// SHA of the commit capturing the unstaged state at archive time.
 169    pub unstaged_commit_hash: String,
 170    /// Whether this worktree has been restored.
 171    pub restored: bool,
 172}
 173
 174/// The store holds all metadata needed to show threads in the sidebar/the archive.
 175///
 176/// Automatically listens to AcpThread events and updates metadata if it has changed.
 177pub struct ThreadMetadataStore {
 178    db: ThreadMetadataDb,
 179    threads: HashMap<acp::SessionId, ThreadMetadata>,
 180    threads_by_paths: HashMap<PathList, HashSet<acp::SessionId>>,
 181    threads_by_main_paths: HashMap<PathList, HashSet<acp::SessionId>>,
 182    reload_task: Option<Shared<Task<()>>>,
 183    session_subscriptions: HashMap<acp::SessionId, Subscription>,
 184    pending_thread_ops_tx: smol::channel::Sender<DbOperation>,
 185    _db_operations_task: Task<()>,
 186}
 187
 188#[derive(Debug, PartialEq)]
 189enum DbOperation {
 190    Upsert(ThreadMetadata),
 191    Delete(acp::SessionId),
 192}
 193
 194impl DbOperation {
 195    fn id(&self) -> &acp::SessionId {
 196        match self {
 197            DbOperation::Upsert(thread) => &thread.session_id,
 198            DbOperation::Delete(session_id) => session_id,
 199        }
 200    }
 201}
 202
 203impl ThreadMetadataStore {
 204    #[cfg(not(any(test, feature = "test-support")))]
 205    pub fn init_global(cx: &mut App) {
 206        if cx.has_global::<Self>() {
 207            return;
 208        }
 209
 210        let db = ThreadMetadataDb::global(cx);
 211        let thread_store = cx.new(|cx| Self::new(db, cx));
 212        cx.set_global(GlobalThreadMetadataStore(thread_store));
 213    }
 214
 215    #[cfg(any(test, feature = "test-support"))]
 216    pub fn init_global(cx: &mut App) {
 217        let thread = std::thread::current();
 218        let test_name = thread.name().unwrap_or("unknown_test");
 219        let db_name = format!("THREAD_METADATA_DB_{}", test_name);
 220        let db = smol::block_on(db::open_test_db::<ThreadMetadataDb>(&db_name));
 221        let thread_store = cx.new(|cx| Self::new(ThreadMetadataDb(db), cx));
 222        cx.set_global(GlobalThreadMetadataStore(thread_store));
 223    }
 224
 225    pub fn try_global(cx: &App) -> Option<Entity<Self>> {
 226        cx.try_global::<GlobalThreadMetadataStore>()
 227            .map(|store| store.0.clone())
 228    }
 229
 230    pub fn global(cx: &App) -> Entity<Self> {
 231        cx.global::<GlobalThreadMetadataStore>().0.clone()
 232    }
 233
 234    pub fn is_empty(&self) -> bool {
 235        self.threads.is_empty()
 236    }
 237
 238    /// Returns all thread IDs.
 239    pub fn entry_ids(&self) -> impl Iterator<Item = acp::SessionId> + '_ {
 240        self.threads.keys().cloned()
 241    }
 242
 243    /// Returns the metadata for a specific thread, if it exists.
 244    pub fn entry(&self, session_id: &acp::SessionId) -> Option<&ThreadMetadata> {
 245        self.threads.get(session_id)
 246    }
 247
 248    /// Returns all threads.
 249    pub fn entries(&self) -> impl Iterator<Item = &ThreadMetadata> + '_ {
 250        self.threads.values()
 251    }
 252
 253    /// Returns all archived threads.
 254    pub fn archived_entries(&self) -> impl Iterator<Item = &ThreadMetadata> + '_ {
 255        self.entries().filter(|t| t.archived)
 256    }
 257
 258    /// Returns all threads for the given path list, excluding archived threads.
 259    pub fn entries_for_path(
 260        &self,
 261        path_list: &PathList,
 262    ) -> impl Iterator<Item = &ThreadMetadata> + '_ {
 263        self.threads_by_paths
 264            .get(path_list)
 265            .into_iter()
 266            .flatten()
 267            .filter_map(|s| self.threads.get(s))
 268            .filter(|s| !s.archived)
 269    }
 270
 271    /// Returns threads whose `main_worktree_paths` matches the given path list,
 272    /// excluding archived threads. This finds threads that were opened in a
 273    /// linked worktree but are associated with the given main worktree.
 274    pub fn entries_for_main_worktree_path(
 275        &self,
 276        path_list: &PathList,
 277    ) -> impl Iterator<Item = &ThreadMetadata> + '_ {
 278        self.threads_by_main_paths
 279            .get(path_list)
 280            .into_iter()
 281            .flatten()
 282            .filter_map(|s| self.threads.get(s))
 283            .filter(|s| !s.archived)
 284    }
 285
 286    fn reload(&mut self, cx: &mut Context<Self>) -> Shared<Task<()>> {
 287        let db = self.db.clone();
 288        self.reload_task.take();
 289
 290        let list_task = cx
 291            .background_spawn(async move { db.list().context("Failed to fetch sidebar metadata") });
 292
 293        let reload_task = cx
 294            .spawn(async move |this, cx| {
 295                let Some(rows) = list_task.await.log_err() else {
 296                    return;
 297                };
 298
 299                this.update(cx, |this, cx| {
 300                    this.threads.clear();
 301                    this.threads_by_paths.clear();
 302                    this.threads_by_main_paths.clear();
 303
 304                    for row in rows {
 305                        this.threads_by_paths
 306                            .entry(row.folder_paths.clone())
 307                            .or_default()
 308                            .insert(row.session_id.clone());
 309                        if !row.main_worktree_paths.is_empty() {
 310                            this.threads_by_main_paths
 311                                .entry(row.main_worktree_paths.clone())
 312                                .or_default()
 313                                .insert(row.session_id.clone());
 314                        }
 315                        this.threads.insert(row.session_id.clone(), row);
 316                    }
 317
 318                    cx.notify();
 319                })
 320                .ok();
 321            })
 322            .shared();
 323        self.reload_task = Some(reload_task.clone());
 324        reload_task
 325    }
 326
 327    pub fn save_all(&mut self, metadata: Vec<ThreadMetadata>, cx: &mut Context<Self>) {
 328        if !cx.has_flag::<AgentV2FeatureFlag>() {
 329            return;
 330        }
 331
 332        for metadata in metadata {
 333            self.save_internal(metadata);
 334        }
 335        cx.notify();
 336    }
 337
 338    #[cfg(any(test, feature = "test-support"))]
 339    pub fn save_manually(&mut self, metadata: ThreadMetadata, cx: &mut Context<Self>) {
 340        self.save(metadata, cx)
 341    }
 342
 343    fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context<Self>) {
 344        if !cx.has_flag::<AgentV2FeatureFlag>() {
 345            return;
 346        }
 347
 348        self.save_internal(metadata);
 349        cx.notify();
 350    }
 351
 352    fn save_internal(&mut self, metadata: ThreadMetadata) {
 353        if let Some(thread) = self.threads.get(&metadata.session_id) {
 354            if thread.folder_paths != metadata.folder_paths {
 355                if let Some(session_ids) = self.threads_by_paths.get_mut(&thread.folder_paths) {
 356                    session_ids.remove(&metadata.session_id);
 357                }
 358            }
 359            if thread.main_worktree_paths != metadata.main_worktree_paths
 360                && !thread.main_worktree_paths.is_empty()
 361            {
 362                if let Some(session_ids) = self
 363                    .threads_by_main_paths
 364                    .get_mut(&thread.main_worktree_paths)
 365                {
 366                    session_ids.remove(&metadata.session_id);
 367                }
 368            }
 369        }
 370
 371        self.threads
 372            .insert(metadata.session_id.clone(), metadata.clone());
 373
 374        self.threads_by_paths
 375            .entry(metadata.folder_paths.clone())
 376            .or_default()
 377            .insert(metadata.session_id.clone());
 378
 379        if !metadata.main_worktree_paths.is_empty() {
 380            self.threads_by_main_paths
 381                .entry(metadata.main_worktree_paths.clone())
 382                .or_default()
 383                .insert(metadata.session_id.clone());
 384        }
 385
 386        self.pending_thread_ops_tx
 387            .try_send(DbOperation::Upsert(metadata))
 388            .log_err();
 389    }
 390
 391    pub fn update_working_directories(
 392        &mut self,
 393        session_id: &acp::SessionId,
 394        work_dirs: PathList,
 395        cx: &mut Context<Self>,
 396    ) {
 397        if !cx.has_flag::<AgentV2FeatureFlag>() {
 398            return;
 399        }
 400
 401        if let Some(thread) = self.threads.get(session_id) {
 402            self.save_internal(ThreadMetadata {
 403                folder_paths: work_dirs,
 404                ..thread.clone()
 405            });
 406            cx.notify();
 407        }
 408    }
 409
 410    pub fn archive(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
 411        self.update_archived(session_id, true, cx);
 412    }
 413
 414    pub fn unarchive(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
 415        self.update_archived(session_id, false, cx);
 416    }
 417
 418    pub fn set_pending_worktree_restore(
 419        &mut self,
 420        session_id: &acp::SessionId,
 421        main_repo_path: Option<PathBuf>,
 422        cx: &mut Context<Self>,
 423    ) {
 424        if let Some(thread) = self.threads.get_mut(session_id) {
 425            thread.pending_worktree_restore = main_repo_path;
 426            cx.notify();
 427        }
 428    }
 429
 430    pub fn complete_worktree_restore(
 431        &mut self,
 432        session_id: &acp::SessionId,
 433        path_replacements: &[(PathBuf, PathBuf)],
 434        cx: &mut Context<Self>,
 435    ) {
 436        if let Some(thread) = self.threads.get(session_id).cloned() {
 437            let mut paths: Vec<PathBuf> = thread.folder_paths.paths().to_vec();
 438            for (old_path, new_path) in path_replacements {
 439                if let Some(pos) = paths.iter().position(|p| p == old_path) {
 440                    paths[pos] = new_path.clone();
 441                }
 442            }
 443            let new_folder_paths = PathList::new(&paths);
 444            self.save_internal(ThreadMetadata {
 445                pending_worktree_restore: None,
 446                folder_paths: new_folder_paths,
 447                ..thread
 448            });
 449            cx.notify();
 450        }
 451    }
 452
 453    pub fn create_archived_worktree(
 454        &self,
 455        worktree_path: &str,
 456        main_repo_path: &str,
 457        branch_name: Option<&str>,
 458        staged_commit_hash: &str,
 459        unstaged_commit_hash: &str,
 460        cx: &App,
 461    ) -> Task<anyhow::Result<i64>> {
 462        let db = self.db.clone();
 463        let worktree_path = worktree_path.to_string();
 464        let main_repo_path = main_repo_path.to_string();
 465        let branch_name = branch_name.map(|s| s.to_string());
 466        let staged_commit_hash = staged_commit_hash.to_string();
 467        let unstaged_commit_hash = unstaged_commit_hash.to_string();
 468        cx.background_spawn(async move {
 469            db.create_archived_worktree(
 470                &worktree_path,
 471                &main_repo_path,
 472                branch_name.as_deref(),
 473                &staged_commit_hash,
 474                &unstaged_commit_hash,
 475            )
 476            .await
 477        })
 478    }
 479
 480    pub fn link_thread_to_archived_worktree(
 481        &self,
 482        session_id: &str,
 483        archived_worktree_id: i64,
 484        cx: &App,
 485    ) -> Task<anyhow::Result<()>> {
 486        let db = self.db.clone();
 487        let session_id = session_id.to_string();
 488        cx.background_spawn(async move {
 489            db.link_thread_to_archived_worktree(&session_id, archived_worktree_id)
 490                .await
 491        })
 492    }
 493
 494    pub fn get_archived_worktrees_for_thread(
 495        &self,
 496        session_id: &str,
 497        cx: &App,
 498    ) -> Task<anyhow::Result<Vec<ArchivedGitWorktree>>> {
 499        let db = self.db.clone();
 500        let session_id = session_id.to_string();
 501        cx.background_spawn(async move { db.get_archived_worktrees_for_thread(&session_id).await })
 502    }
 503
 504    pub fn delete_archived_worktree(&self, id: i64, cx: &App) -> Task<anyhow::Result<()>> {
 505        let db = self.db.clone();
 506        cx.background_spawn(async move { db.delete_archived_worktree(id).await })
 507    }
 508
 509    pub fn set_archived_worktree_restored(
 510        &self,
 511        id: i64,
 512        worktree_path: &str,
 513        branch_name: Option<&str>,
 514        cx: &App,
 515    ) -> Task<anyhow::Result<()>> {
 516        let db = self.db.clone();
 517        let worktree_path = worktree_path.to_string();
 518        let branch_name = branch_name.map(|s| s.to_string());
 519        cx.background_spawn(async move {
 520            db.set_archived_worktree_restored(id, &worktree_path, branch_name.as_deref())
 521                .await
 522        })
 523    }
 524
 525    pub fn all_session_ids_for_path<'a>(
 526        &'a self,
 527        path_list: &PathList,
 528    ) -> impl Iterator<Item = &'a acp::SessionId> {
 529        self.threads_by_paths
 530            .get(path_list)
 531            .into_iter()
 532            .flat_map(|session_ids| session_ids.iter())
 533    }
 534
 535    fn update_archived(
 536        &mut self,
 537        session_id: &acp::SessionId,
 538        archived: bool,
 539        cx: &mut Context<Self>,
 540    ) {
 541        if !cx.has_flag::<AgentV2FeatureFlag>() {
 542            return;
 543        }
 544
 545        if let Some(thread) = self.threads.get(session_id) {
 546            self.save_internal(ThreadMetadata {
 547                archived,
 548                ..thread.clone()
 549            });
 550            cx.notify();
 551        }
 552    }
 553
 554    pub fn delete(&mut self, session_id: acp::SessionId, cx: &mut Context<Self>) {
 555        if !cx.has_flag::<AgentV2FeatureFlag>() {
 556            return;
 557        }
 558
 559        if let Some(thread) = self.threads.get(&session_id) {
 560            if let Some(session_ids) = self.threads_by_paths.get_mut(&thread.folder_paths) {
 561                session_ids.remove(&session_id);
 562            }
 563            if !thread.main_worktree_paths.is_empty() {
 564                if let Some(session_ids) = self
 565                    .threads_by_main_paths
 566                    .get_mut(&thread.main_worktree_paths)
 567                {
 568                    session_ids.remove(&session_id);
 569                }
 570            }
 571        }
 572        self.threads.remove(&session_id);
 573        self.pending_thread_ops_tx
 574            .try_send(DbOperation::Delete(session_id))
 575            .log_err();
 576        cx.notify();
 577    }
 578
 579    fn new(db: ThreadMetadataDb, cx: &mut Context<Self>) -> Self {
 580        let weak_store = cx.weak_entity();
 581
 582        cx.observe_new::<acp_thread::AcpThread>(move |thread, _window, cx| {
 583            // Don't track subagent threads in the sidebar.
 584            if thread.parent_session_id().is_some() {
 585                return;
 586            }
 587
 588            let thread_entity = cx.entity();
 589
 590            cx.on_release({
 591                let weak_store = weak_store.clone();
 592                move |thread, cx| {
 593                    weak_store
 594                        .update(cx, |store, _cx| {
 595                            let session_id = thread.session_id().clone();
 596                            store.session_subscriptions.remove(&session_id);
 597                        })
 598                        .ok();
 599                }
 600            })
 601            .detach();
 602
 603            weak_store
 604                .update(cx, |this, cx| {
 605                    let subscription = cx.subscribe(&thread_entity, Self::handle_thread_event);
 606                    this.session_subscriptions
 607                        .insert(thread.session_id().clone(), subscription);
 608                })
 609                .ok();
 610        })
 611        .detach();
 612
 613        let (tx, rx) = smol::channel::unbounded();
 614        let _db_operations_task = cx.background_spawn({
 615            let db = db.clone();
 616            async move {
 617                while let Ok(first_update) = rx.recv().await {
 618                    let mut updates = vec![first_update];
 619                    while let Ok(update) = rx.try_recv() {
 620                        updates.push(update);
 621                    }
 622                    let updates = Self::dedup_db_operations(updates);
 623                    for operation in updates {
 624                        match operation {
 625                            DbOperation::Upsert(metadata) => {
 626                                db.save(metadata).await.log_err();
 627                            }
 628                            DbOperation::Delete(session_id) => {
 629                                db.delete(session_id).await.log_err();
 630                            }
 631                        }
 632                    }
 633                }
 634            }
 635        });
 636
 637        let mut this = Self {
 638            db,
 639            threads: HashMap::default(),
 640            threads_by_paths: HashMap::default(),
 641            threads_by_main_paths: HashMap::default(),
 642            reload_task: None,
 643            session_subscriptions: HashMap::default(),
 644            pending_thread_ops_tx: tx,
 645            _db_operations_task,
 646        };
 647        let _ = this.reload(cx);
 648        this
 649    }
 650
 651    fn dedup_db_operations(operations: Vec<DbOperation>) -> Vec<DbOperation> {
 652        let mut ops = HashMap::default();
 653        for operation in operations.into_iter().rev() {
 654            if ops.contains_key(operation.id()) {
 655                continue;
 656            }
 657            ops.insert(operation.id().clone(), operation);
 658        }
 659        ops.into_values().collect()
 660    }
 661
 662    fn handle_thread_event(
 663        &mut self,
 664        thread: Entity<acp_thread::AcpThread>,
 665        event: &AcpThreadEvent,
 666        cx: &mut Context<Self>,
 667    ) {
 668        // Don't track subagent threads in the sidebar.
 669        if thread.read(cx).parent_session_id().is_some() {
 670            return;
 671        }
 672
 673        match event {
 674            AcpThreadEvent::NewEntry
 675            | AcpThreadEvent::TitleUpdated
 676            | AcpThreadEvent::EntryUpdated(_)
 677            | AcpThreadEvent::EntriesRemoved(_)
 678            | AcpThreadEvent::ToolAuthorizationRequested(_)
 679            | AcpThreadEvent::ToolAuthorizationReceived(_)
 680            | AcpThreadEvent::Retry(_)
 681            | AcpThreadEvent::Stopped(_)
 682            | AcpThreadEvent::Error
 683            | AcpThreadEvent::LoadError(_)
 684            | AcpThreadEvent::Refusal
 685            | AcpThreadEvent::WorkingDirectoriesUpdated => {
 686                let thread_ref = thread.read(cx);
 687                if thread_ref.entries().is_empty() {
 688                    return;
 689                }
 690
 691                let existing_thread = self.threads.get(thread_ref.session_id());
 692                let session_id = thread_ref.session_id().clone();
 693                let title = thread_ref
 694                    .title()
 695                    .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
 696
 697                let updated_at = Utc::now();
 698
 699                let created_at = existing_thread
 700                    .and_then(|t| t.created_at)
 701                    .unwrap_or_else(|| updated_at);
 702
 703                let agent_id = thread_ref.connection().agent_id();
 704
 705                let folder_paths = {
 706                    let project = thread_ref.project().read(cx);
 707                    let paths: Vec<Arc<Path>> = project
 708                        .visible_worktrees(cx)
 709                        .map(|worktree| worktree.read(cx).abs_path())
 710                        .collect();
 711                    PathList::new(&paths)
 712                };
 713
 714                let main_worktree_paths = {
 715                    let project = thread_ref.project().read(cx);
 716                    let mut main_paths: Vec<Arc<Path>> = Vec::new();
 717                    for repo in project.repositories(cx).values() {
 718                        let snapshot = repo.read(cx).snapshot();
 719                        if snapshot.is_linked_worktree() {
 720                            main_paths.push(snapshot.original_repo_abs_path.clone());
 721                        }
 722                    }
 723                    main_paths.sort();
 724                    main_paths.dedup();
 725                    PathList::new(&main_paths)
 726                };
 727
 728                // Threads without a folder path (e.g. started in an empty
 729                // window) are archived by default so they don't get lost,
 730                // because they won't show up in the sidebar. Users can reload
 731                // them from the archive.
 732                let archived = existing_thread
 733                    .map(|t| t.archived)
 734                    .unwrap_or(folder_paths.is_empty());
 735
 736                let metadata = ThreadMetadata {
 737                    session_id,
 738                    agent_id,
 739                    title,
 740                    created_at: Some(created_at),
 741                    updated_at,
 742                    folder_paths,
 743                    main_worktree_paths,
 744                    archived,
 745                    pending_worktree_restore: None,
 746                };
 747
 748                self.save(metadata, cx);
 749            }
 750            AcpThreadEvent::TokenUsageUpdated
 751            | AcpThreadEvent::SubagentSpawned(_)
 752            | AcpThreadEvent::PromptCapabilitiesUpdated
 753            | AcpThreadEvent::AvailableCommandsUpdated(_)
 754            | AcpThreadEvent::ModeUpdated(_)
 755            | AcpThreadEvent::ConfigOptionsUpdated(_) => {}
 756        }
 757    }
 758}
 759
 760impl Global for ThreadMetadataStore {}
 761
 762struct ThreadMetadataDb(ThreadSafeConnection);
 763
 764impl Domain for ThreadMetadataDb {
 765    const NAME: &str = stringify!(ThreadMetadataDb);
 766
 767    const MIGRATIONS: &[&str] = &[
 768        sql!(
 769            CREATE TABLE IF NOT EXISTS sidebar_threads(
 770                session_id TEXT PRIMARY KEY,
 771                agent_id TEXT,
 772                title TEXT NOT NULL,
 773                updated_at TEXT NOT NULL,
 774                created_at TEXT,
 775                folder_paths TEXT,
 776                folder_paths_order TEXT
 777            ) STRICT;
 778        ),
 779        sql!(ALTER TABLE sidebar_threads ADD COLUMN archived INTEGER DEFAULT 0),
 780        sql!(ALTER TABLE sidebar_threads ADD COLUMN main_worktree_paths TEXT),
 781        sql!(ALTER TABLE sidebar_threads ADD COLUMN main_worktree_paths_order TEXT),
 782        sql!(
 783            CREATE TABLE IF NOT EXISTS archived_git_worktrees(
 784                id INTEGER PRIMARY KEY,
 785                worktree_path TEXT NOT NULL,
 786                main_repo_path TEXT NOT NULL,
 787                branch_name TEXT,
 788                commit_hash TEXT NOT NULL,
 789                restored INTEGER NOT NULL DEFAULT 0
 790            ) STRICT;
 791
 792            CREATE TABLE IF NOT EXISTS thread_archived_worktrees(
 793                session_id TEXT NOT NULL,
 794                archived_worktree_id INTEGER NOT NULL REFERENCES archived_git_worktrees(id),
 795                PRIMARY KEY (session_id, archived_worktree_id)
 796            ) STRICT;
 797        ),
 798        sql!(
 799            ALTER TABLE archived_git_worktrees ADD COLUMN staged_commit_hash TEXT;
 800            ALTER TABLE archived_git_worktrees ADD COLUMN unstaged_commit_hash TEXT;
 801            UPDATE archived_git_worktrees SET staged_commit_hash = commit_hash, unstaged_commit_hash = commit_hash WHERE staged_commit_hash IS NULL;
 802        ),
 803    ];
 804}
 805
 806db::static_connection!(ThreadMetadataDb, []);
 807
 808impl ThreadMetadataDb {
 809    pub fn list_ids(&self) -> anyhow::Result<Vec<Arc<str>>> {
 810        self.select::<Arc<str>>(
 811            "SELECT session_id FROM sidebar_threads \
 812             ORDER BY updated_at DESC",
 813        )?()
 814    }
 815
 816    /// List all sidebar thread metadata, ordered by updated_at descending.
 817    pub fn list(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
 818        self.select::<ThreadMetadata>(
 819            "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived, main_worktree_paths, main_worktree_paths_order \
 820             FROM sidebar_threads \
 821             ORDER BY updated_at DESC"
 822        )?()
 823    }
 824
 825    /// Upsert metadata for a thread.
 826    pub async fn save(&self, row: ThreadMetadata) -> anyhow::Result<()> {
 827        let id = row.session_id.0.clone();
 828        let agent_id = if row.agent_id.as_ref() == ZED_AGENT_ID.as_ref() {
 829            None
 830        } else {
 831            Some(row.agent_id.to_string())
 832        };
 833        let title = row.title.to_string();
 834        let updated_at = row.updated_at.to_rfc3339();
 835        let created_at = row.created_at.map(|dt| dt.to_rfc3339());
 836        let serialized = row.folder_paths.serialize();
 837        let (folder_paths, folder_paths_order) = if row.folder_paths.is_empty() {
 838            (None, None)
 839        } else {
 840            (Some(serialized.paths), Some(serialized.order))
 841        };
 842        let main_serialized = row.main_worktree_paths.serialize();
 843        let (main_worktree_paths, main_worktree_paths_order) = if row.main_worktree_paths.is_empty()
 844        {
 845            (None, None)
 846        } else {
 847            (Some(main_serialized.paths), Some(main_serialized.order))
 848        };
 849        let archived = row.archived;
 850
 851        self.write(move |conn| {
 852            let sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived, main_worktree_paths, main_worktree_paths_order) \
 853                       VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \
 854                       ON CONFLICT(session_id) DO UPDATE SET \
 855                           agent_id = excluded.agent_id, \
 856                           title = excluded.title, \
 857                           updated_at = excluded.updated_at, \
 858                           created_at = excluded.created_at, \
 859                           folder_paths = excluded.folder_paths, \
 860                           folder_paths_order = excluded.folder_paths_order, \
 861                           archived = excluded.archived, \
 862                           main_worktree_paths = excluded.main_worktree_paths, \
 863                           main_worktree_paths_order = excluded.main_worktree_paths_order";
 864            let mut stmt = Statement::prepare(conn, sql)?;
 865            let mut i = stmt.bind(&id, 1)?;
 866            i = stmt.bind(&agent_id, i)?;
 867            i = stmt.bind(&title, i)?;
 868            i = stmt.bind(&updated_at, i)?;
 869            i = stmt.bind(&created_at, i)?;
 870            i = stmt.bind(&folder_paths, i)?;
 871            i = stmt.bind(&folder_paths_order, i)?;
 872            i = stmt.bind(&archived, i)?;
 873            i = stmt.bind(&main_worktree_paths, i)?;
 874            stmt.bind(&main_worktree_paths_order, i)?;
 875            stmt.exec()
 876        })
 877        .await
 878    }
 879
 880    /// Delete metadata for a single thread.
 881    pub async fn delete(&self, session_id: acp::SessionId) -> anyhow::Result<()> {
 882        let id = session_id.0.clone();
 883        self.write(move |conn| {
 884            let mut stmt =
 885                Statement::prepare(conn, "DELETE FROM sidebar_threads WHERE session_id = ?")?;
 886            stmt.bind(&id, 1)?;
 887            stmt.exec()
 888        })
 889        .await
 890    }
 891
 892    pub async fn create_archived_worktree(
 893        &self,
 894        worktree_path: &str,
 895        main_repo_path: &str,
 896        branch_name: Option<&str>,
 897        staged_commit_hash: &str,
 898        unstaged_commit_hash: &str,
 899    ) -> anyhow::Result<i64> {
 900        let worktree_path = worktree_path.to_string();
 901        let main_repo_path = main_repo_path.to_string();
 902        let branch_name = branch_name.map(|s| s.to_string());
 903        let staged_commit_hash = staged_commit_hash.to_string();
 904        let unstaged_commit_hash = unstaged_commit_hash.to_string();
 905
 906        self.write(move |conn| {
 907            let mut stmt = Statement::prepare(
 908                conn,
 909                "INSERT INTO archived_git_worktrees(worktree_path, main_repo_path, branch_name, commit_hash, staged_commit_hash, unstaged_commit_hash) \
 910                 VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
 911                 RETURNING id",
 912            )?;
 913            let mut i = stmt.bind(&worktree_path, 1)?;
 914            i = stmt.bind(&main_repo_path, i)?;
 915            i = stmt.bind(&branch_name, i)?;
 916            i = stmt.bind(&unstaged_commit_hash, i)?;
 917            i = stmt.bind(&staged_commit_hash, i)?;
 918            stmt.bind(&unstaged_commit_hash, i)?;
 919            stmt.maybe_row::<i64>()?.context("expected RETURNING id")
 920        })
 921        .await
 922    }
 923
 924    pub async fn link_thread_to_archived_worktree(
 925        &self,
 926        session_id: &str,
 927        archived_worktree_id: i64,
 928    ) -> anyhow::Result<()> {
 929        let session_id = session_id.to_string();
 930
 931        self.write(move |conn| {
 932            let mut stmt = Statement::prepare(
 933                conn,
 934                "INSERT INTO thread_archived_worktrees(session_id, archived_worktree_id) \
 935                 VALUES (?1, ?2)",
 936            )?;
 937            let i = stmt.bind(&session_id, 1)?;
 938            stmt.bind(&archived_worktree_id, i)?;
 939            stmt.exec()
 940        })
 941        .await
 942    }
 943
 944    pub async fn get_archived_worktrees_for_thread(
 945        &self,
 946        session_id: &str,
 947    ) -> anyhow::Result<Vec<ArchivedGitWorktree>> {
 948        let session_id = session_id.to_string();
 949
 950        self.select_bound::<String, ArchivedGitWorktree>(
 951            "SELECT a.id, a.worktree_path, a.main_repo_path, a.branch_name, a.staged_commit_hash, a.unstaged_commit_hash, a.restored \
 952             FROM archived_git_worktrees a \
 953             JOIN thread_archived_worktrees t ON a.id = t.archived_worktree_id \
 954             WHERE t.session_id = ?1",
 955        )?(session_id)
 956    }
 957
 958    pub async fn delete_archived_worktree(&self, id: i64) -> anyhow::Result<()> {
 959        self.write(move |conn| {
 960            let mut stmt = Statement::prepare(
 961                conn,
 962                "DELETE FROM thread_archived_worktrees WHERE archived_worktree_id = ?",
 963            )?;
 964            stmt.bind(&id, 1)?;
 965            stmt.exec()?;
 966
 967            let mut stmt =
 968                Statement::prepare(conn, "DELETE FROM archived_git_worktrees WHERE id = ?")?;
 969            stmt.bind(&id, 1)?;
 970            stmt.exec()
 971        })
 972        .await
 973    }
 974
 975    pub async fn set_archived_worktree_restored(
 976        &self,
 977        id: i64,
 978        worktree_path: &str,
 979        branch_name: Option<&str>,
 980    ) -> anyhow::Result<()> {
 981        let worktree_path = worktree_path.to_string();
 982        let branch_name = branch_name.map(|s| s.to_string());
 983
 984        self.write(move |conn| {
 985            let mut stmt = Statement::prepare(
 986                conn,
 987                "UPDATE archived_git_worktrees SET restored = 1, worktree_path = ?1, branch_name = ?2 WHERE id = ?3",
 988            )?;
 989            let mut i = stmt.bind(&worktree_path, 1)?;
 990            i = stmt.bind(&branch_name, i)?;
 991            stmt.bind(&id, i)?;
 992            stmt.exec()
 993        })
 994        .await
 995    }
 996}
 997
 998impl Column for ThreadMetadata {
 999    fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
1000        let (id, next): (Arc<str>, i32) = Column::column(statement, start_index)?;
1001        let (agent_id, next): (Option<String>, i32) = Column::column(statement, next)?;
1002        let (title, next): (String, i32) = Column::column(statement, next)?;
1003        let (updated_at_str, next): (String, i32) = Column::column(statement, next)?;
1004        let (created_at_str, next): (Option<String>, i32) = Column::column(statement, next)?;
1005        let (folder_paths_str, next): (Option<String>, i32) = Column::column(statement, next)?;
1006        let (folder_paths_order_str, next): (Option<String>, i32) =
1007            Column::column(statement, next)?;
1008        let (archived, next): (bool, i32) = Column::column(statement, next)?;
1009        let (main_worktree_paths_str, next): (Option<String>, i32) =
1010            Column::column(statement, next)?;
1011        let (main_worktree_paths_order_str, next): (Option<String>, i32) =
1012            Column::column(statement, next)?;
1013
1014        let agent_id = agent_id
1015            .map(|id| AgentId::new(id))
1016            .unwrap_or(ZED_AGENT_ID.clone());
1017
1018        let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc);
1019        let created_at = created_at_str
1020            .as_deref()
1021            .map(DateTime::parse_from_rfc3339)
1022            .transpose()?
1023            .map(|dt| dt.with_timezone(&Utc));
1024
1025        let folder_paths = folder_paths_str
1026            .map(|paths| {
1027                PathList::deserialize(&util::path_list::SerializedPathList {
1028                    paths,
1029                    order: folder_paths_order_str.unwrap_or_default(),
1030                })
1031            })
1032            .unwrap_or_default();
1033
1034        let main_worktree_paths = main_worktree_paths_str
1035            .map(|paths| {
1036                PathList::deserialize(&util::path_list::SerializedPathList {
1037                    paths,
1038                    order: main_worktree_paths_order_str.unwrap_or_default(),
1039                })
1040            })
1041            .unwrap_or_default();
1042
1043        Ok((
1044            ThreadMetadata {
1045                session_id: acp::SessionId::new(id),
1046                agent_id,
1047                title: title.into(),
1048                updated_at,
1049                created_at,
1050                folder_paths,
1051                main_worktree_paths,
1052                archived,
1053                pending_worktree_restore: None,
1054            },
1055            next,
1056        ))
1057    }
1058}
1059
1060impl Column for ArchivedGitWorktree {
1061    fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
1062        let (id, next): (i64, i32) = Column::column(statement, start_index)?;
1063        let (worktree_path_str, next): (String, i32) = Column::column(statement, next)?;
1064        let (main_repo_path_str, next): (String, i32) = Column::column(statement, next)?;
1065        let (branch_name, next): (Option<String>, i32) = Column::column(statement, next)?;
1066        let (staged_commit_hash, next): (String, i32) = Column::column(statement, next)?;
1067        let (unstaged_commit_hash, next): (String, i32) = Column::column(statement, next)?;
1068        let (restored_int, next): (i64, i32) = Column::column(statement, next)?;
1069
1070        Ok((
1071            ArchivedGitWorktree {
1072                id,
1073                worktree_path: PathBuf::from(worktree_path_str),
1074                main_repo_path: PathBuf::from(main_repo_path_str),
1075                branch_name,
1076                staged_commit_hash,
1077                unstaged_commit_hash,
1078                restored: restored_int != 0,
1079            },
1080            next,
1081        ))
1082    }
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087    use super::*;
1088    use acp_thread::{AgentConnection, StubAgentConnection};
1089    use action_log::ActionLog;
1090    use agent::DbThread;
1091    use agent_client_protocol as acp;
1092    use feature_flags::FeatureFlagAppExt;
1093    use gpui::TestAppContext;
1094    use project::FakeFs;
1095    use project::Project;
1096    use std::path::Path;
1097    use std::rc::Rc;
1098
1099    fn make_db_thread(title: &str, updated_at: DateTime<Utc>) -> DbThread {
1100        DbThread {
1101            title: title.to_string().into(),
1102            messages: Vec::new(),
1103            updated_at,
1104            detailed_summary: None,
1105            initial_project_snapshot: None,
1106            cumulative_token_usage: Default::default(),
1107            request_token_usage: Default::default(),
1108            model: None,
1109            profile: None,
1110            imported: false,
1111            subagent_context: None,
1112            speed: None,
1113            thinking_enabled: false,
1114            thinking_effort: None,
1115            draft_prompt: None,
1116            ui_scroll_position: None,
1117        }
1118    }
1119
1120    fn make_metadata(
1121        session_id: &str,
1122        title: &str,
1123        updated_at: DateTime<Utc>,
1124        folder_paths: PathList,
1125    ) -> ThreadMetadata {
1126        ThreadMetadata {
1127            archived: false,
1128            session_id: acp::SessionId::new(session_id),
1129            agent_id: agent::ZED_AGENT_ID.clone(),
1130            title: title.to_string().into(),
1131            updated_at,
1132            created_at: Some(updated_at),
1133            folder_paths,
1134            main_worktree_paths: PathList::default(),
1135            pending_worktree_restore: None,
1136        }
1137    }
1138
1139    fn init_test(cx: &mut TestAppContext) {
1140        cx.update(|cx| {
1141            let settings_store = settings::SettingsStore::test(cx);
1142            cx.set_global(settings_store);
1143            cx.update_flags(true, vec!["agent-v2".to_string()]);
1144            ThreadMetadataStore::init_global(cx);
1145            ThreadStore::init_global(cx);
1146        });
1147        cx.run_until_parked();
1148    }
1149
1150    #[gpui::test]
1151    async fn test_store_initializes_cache_from_database(cx: &mut TestAppContext) {
1152        let first_paths = PathList::new(&[Path::new("/project-a")]);
1153        let second_paths = PathList::new(&[Path::new("/project-b")]);
1154        let now = Utc::now();
1155        let older = now - chrono::Duration::seconds(1);
1156
1157        let thread = std::thread::current();
1158        let test_name = thread.name().unwrap_or("unknown_test");
1159        let db_name = format!("THREAD_METADATA_DB_{}", test_name);
1160        let db = ThreadMetadataDb(smol::block_on(db::open_test_db::<ThreadMetadataDb>(
1161            &db_name,
1162        )));
1163
1164        db.save(make_metadata(
1165            "session-1",
1166            "First Thread",
1167            now,
1168            first_paths.clone(),
1169        ))
1170        .await
1171        .unwrap();
1172        db.save(make_metadata(
1173            "session-2",
1174            "Second Thread",
1175            older,
1176            second_paths.clone(),
1177        ))
1178        .await
1179        .unwrap();
1180
1181        cx.update(|cx| {
1182            let settings_store = settings::SettingsStore::test(cx);
1183            cx.set_global(settings_store);
1184            cx.update_flags(true, vec!["agent-v2".to_string()]);
1185            ThreadMetadataStore::init_global(cx);
1186        });
1187
1188        cx.run_until_parked();
1189
1190        cx.update(|cx| {
1191            let store = ThreadMetadataStore::global(cx);
1192            let store = store.read(cx);
1193
1194            let entry_ids = store
1195                .entry_ids()
1196                .map(|session_id| session_id.0.to_string())
1197                .collect::<Vec<_>>();
1198            assert_eq!(entry_ids.len(), 2);
1199            assert!(entry_ids.contains(&"session-1".to_string()));
1200            assert!(entry_ids.contains(&"session-2".to_string()));
1201
1202            let first_path_entries = store
1203                .entries_for_path(&first_paths)
1204                .map(|entry| entry.session_id.0.to_string())
1205                .collect::<Vec<_>>();
1206            assert_eq!(first_path_entries, vec!["session-1"]);
1207
1208            let second_path_entries = store
1209                .entries_for_path(&second_paths)
1210                .map(|entry| entry.session_id.0.to_string())
1211                .collect::<Vec<_>>();
1212            assert_eq!(second_path_entries, vec!["session-2"]);
1213        });
1214    }
1215
1216    #[gpui::test]
1217    async fn test_store_cache_updates_after_save_and_delete(cx: &mut TestAppContext) {
1218        init_test(cx);
1219
1220        let first_paths = PathList::new(&[Path::new("/project-a")]);
1221        let second_paths = PathList::new(&[Path::new("/project-b")]);
1222        let initial_time = Utc::now();
1223        let updated_time = initial_time + chrono::Duration::seconds(1);
1224
1225        let initial_metadata = make_metadata(
1226            "session-1",
1227            "First Thread",
1228            initial_time,
1229            first_paths.clone(),
1230        );
1231
1232        let second_metadata = make_metadata(
1233            "session-2",
1234            "Second Thread",
1235            initial_time,
1236            second_paths.clone(),
1237        );
1238
1239        cx.update(|cx| {
1240            let store = ThreadMetadataStore::global(cx);
1241            store.update(cx, |store, cx| {
1242                store.save(initial_metadata, cx);
1243                store.save(second_metadata, cx);
1244            });
1245        });
1246
1247        cx.run_until_parked();
1248
1249        cx.update(|cx| {
1250            let store = ThreadMetadataStore::global(cx);
1251            let store = store.read(cx);
1252
1253            let first_path_entries = store
1254                .entries_for_path(&first_paths)
1255                .map(|entry| entry.session_id.0.to_string())
1256                .collect::<Vec<_>>();
1257            assert_eq!(first_path_entries, vec!["session-1"]);
1258
1259            let second_path_entries = store
1260                .entries_for_path(&second_paths)
1261                .map(|entry| entry.session_id.0.to_string())
1262                .collect::<Vec<_>>();
1263            assert_eq!(second_path_entries, vec!["session-2"]);
1264        });
1265
1266        let moved_metadata = make_metadata(
1267            "session-1",
1268            "First Thread",
1269            updated_time,
1270            second_paths.clone(),
1271        );
1272
1273        cx.update(|cx| {
1274            let store = ThreadMetadataStore::global(cx);
1275            store.update(cx, |store, cx| {
1276                store.save(moved_metadata, cx);
1277            });
1278        });
1279
1280        cx.run_until_parked();
1281
1282        cx.update(|cx| {
1283            let store = ThreadMetadataStore::global(cx);
1284            let store = store.read(cx);
1285
1286            let entry_ids = store
1287                .entry_ids()
1288                .map(|session_id| session_id.0.to_string())
1289                .collect::<Vec<_>>();
1290            assert_eq!(entry_ids.len(), 2);
1291            assert!(entry_ids.contains(&"session-1".to_string()));
1292            assert!(entry_ids.contains(&"session-2".to_string()));
1293
1294            let first_path_entries = store
1295                .entries_for_path(&first_paths)
1296                .map(|entry| entry.session_id.0.to_string())
1297                .collect::<Vec<_>>();
1298            assert!(first_path_entries.is_empty());
1299
1300            let second_path_entries = store
1301                .entries_for_path(&second_paths)
1302                .map(|entry| entry.session_id.0.to_string())
1303                .collect::<Vec<_>>();
1304            assert_eq!(second_path_entries.len(), 2);
1305            assert!(second_path_entries.contains(&"session-1".to_string()));
1306            assert!(second_path_entries.contains(&"session-2".to_string()));
1307        });
1308
1309        cx.update(|cx| {
1310            let store = ThreadMetadataStore::global(cx);
1311            store.update(cx, |store, cx| {
1312                store.delete(acp::SessionId::new("session-2"), cx);
1313            });
1314        });
1315
1316        cx.run_until_parked();
1317
1318        cx.update(|cx| {
1319            let store = ThreadMetadataStore::global(cx);
1320            let store = store.read(cx);
1321
1322            let entry_ids = store
1323                .entry_ids()
1324                .map(|session_id| session_id.0.to_string())
1325                .collect::<Vec<_>>();
1326            assert_eq!(entry_ids, vec!["session-1"]);
1327
1328            let second_path_entries = store
1329                .entries_for_path(&second_paths)
1330                .map(|entry| entry.session_id.0.to_string())
1331                .collect::<Vec<_>>();
1332            assert_eq!(second_path_entries, vec!["session-1"]);
1333        });
1334    }
1335
1336    #[gpui::test]
1337    async fn test_migrate_thread_metadata_migrates_only_missing_threads(cx: &mut TestAppContext) {
1338        init_test(cx);
1339
1340        let project_a_paths = PathList::new(&[Path::new("/project-a")]);
1341        let project_b_paths = PathList::new(&[Path::new("/project-b")]);
1342        let now = Utc::now();
1343
1344        let existing_metadata = ThreadMetadata {
1345            session_id: acp::SessionId::new("a-session-0"),
1346            agent_id: agent::ZED_AGENT_ID.clone(),
1347            title: "Existing Metadata".into(),
1348            updated_at: now - chrono::Duration::seconds(10),
1349            created_at: Some(now - chrono::Duration::seconds(10)),
1350            folder_paths: project_a_paths.clone(),
1351            main_worktree_paths: PathList::default(),
1352            archived: false,
1353            pending_worktree_restore: None,
1354        };
1355
1356        cx.update(|cx| {
1357            let store = ThreadMetadataStore::global(cx);
1358            store.update(cx, |store, cx| {
1359                store.save(existing_metadata, cx);
1360            });
1361        });
1362        cx.run_until_parked();
1363
1364        let threads_to_save = vec![
1365            (
1366                "a-session-0",
1367                "Thread A0 From Native Store",
1368                project_a_paths.clone(),
1369                now,
1370            ),
1371            (
1372                "a-session-1",
1373                "Thread A1",
1374                project_a_paths.clone(),
1375                now + chrono::Duration::seconds(1),
1376            ),
1377            (
1378                "b-session-0",
1379                "Thread B0",
1380                project_b_paths.clone(),
1381                now + chrono::Duration::seconds(2),
1382            ),
1383            (
1384                "projectless",
1385                "Projectless",
1386                PathList::default(),
1387                now + chrono::Duration::seconds(3),
1388            ),
1389        ];
1390
1391        for (session_id, title, paths, updated_at) in &threads_to_save {
1392            let save_task = cx.update(|cx| {
1393                let thread_store = ThreadStore::global(cx);
1394                let session_id = session_id.to_string();
1395                let title = title.to_string();
1396                let paths = paths.clone();
1397                thread_store.update(cx, |store, cx| {
1398                    store.save_thread(
1399                        acp::SessionId::new(session_id),
1400                        make_db_thread(&title, *updated_at),
1401                        paths,
1402                        cx,
1403                    )
1404                })
1405            });
1406            save_task.await.unwrap();
1407            cx.run_until_parked();
1408        }
1409
1410        cx.update(|cx| migrate_thread_metadata(cx));
1411        cx.run_until_parked();
1412
1413        let list = cx.update(|cx| {
1414            let store = ThreadMetadataStore::global(cx);
1415            store.read(cx).entries().cloned().collect::<Vec<_>>()
1416        });
1417
1418        assert_eq!(list.len(), 4);
1419        assert!(
1420            list.iter()
1421                .all(|metadata| metadata.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref())
1422        );
1423
1424        let existing_metadata = list
1425            .iter()
1426            .find(|metadata| metadata.session_id.0.as_ref() == "a-session-0")
1427            .unwrap();
1428        assert_eq!(existing_metadata.title.as_ref(), "Existing Metadata");
1429        assert!(!existing_metadata.archived);
1430
1431        let migrated_session_ids = list
1432            .iter()
1433            .map(|metadata| metadata.session_id.0.as_ref())
1434            .collect::<Vec<_>>();
1435        assert!(migrated_session_ids.contains(&"a-session-1"));
1436        assert!(migrated_session_ids.contains(&"b-session-0"));
1437        assert!(migrated_session_ids.contains(&"projectless"));
1438
1439        let migrated_entries = list
1440            .iter()
1441            .filter(|metadata| metadata.session_id.0.as_ref() != "a-session-0")
1442            .collect::<Vec<_>>();
1443        assert!(migrated_entries.iter().all(|metadata| metadata.archived));
1444    }
1445
1446    #[gpui::test]
1447    async fn test_migrate_thread_metadata_noops_when_all_threads_already_exist(
1448        cx: &mut TestAppContext,
1449    ) {
1450        init_test(cx);
1451
1452        let project_paths = PathList::new(&[Path::new("/project-a")]);
1453        let existing_updated_at = Utc::now();
1454
1455        let existing_metadata = ThreadMetadata {
1456            session_id: acp::SessionId::new("existing-session"),
1457            agent_id: agent::ZED_AGENT_ID.clone(),
1458            title: "Existing Metadata".into(),
1459            updated_at: existing_updated_at,
1460            created_at: Some(existing_updated_at),
1461            folder_paths: project_paths.clone(),
1462            main_worktree_paths: PathList::default(),
1463            archived: false,
1464            pending_worktree_restore: None,
1465        };
1466
1467        cx.update(|cx| {
1468            let store = ThreadMetadataStore::global(cx);
1469            store.update(cx, |store, cx| {
1470                store.save(existing_metadata, cx);
1471            });
1472        });
1473        cx.run_until_parked();
1474
1475        let save_task = cx.update(|cx| {
1476            let thread_store = ThreadStore::global(cx);
1477            thread_store.update(cx, |store, cx| {
1478                store.save_thread(
1479                    acp::SessionId::new("existing-session"),
1480                    make_db_thread(
1481                        "Updated Native Thread Title",
1482                        existing_updated_at + chrono::Duration::seconds(1),
1483                    ),
1484                    project_paths.clone(),
1485                    cx,
1486                )
1487            })
1488        });
1489        save_task.await.unwrap();
1490        cx.run_until_parked();
1491
1492        cx.update(|cx| migrate_thread_metadata(cx));
1493        cx.run_until_parked();
1494
1495        let list = cx.update(|cx| {
1496            let store = ThreadMetadataStore::global(cx);
1497            store.read(cx).entries().cloned().collect::<Vec<_>>()
1498        });
1499
1500        assert_eq!(list.len(), 1);
1501        assert_eq!(list[0].session_id.0.as_ref(), "existing-session");
1502    }
1503
1504    #[gpui::test]
1505    async fn test_migrate_thread_metadata_archives_beyond_five_most_recent_per_project(
1506        cx: &mut TestAppContext,
1507    ) {
1508        init_test(cx);
1509
1510        let project_a_paths = PathList::new(&[Path::new("/project-a")]);
1511        let project_b_paths = PathList::new(&[Path::new("/project-b")]);
1512        let now = Utc::now();
1513
1514        // Create 7 threads for project A and 3 for project B
1515        let mut threads_to_save = Vec::new();
1516        for i in 0..7 {
1517            threads_to_save.push((
1518                format!("a-session-{i}"),
1519                format!("Thread A{i}"),
1520                project_a_paths.clone(),
1521                now + chrono::Duration::seconds(i as i64),
1522            ));
1523        }
1524        for i in 0..3 {
1525            threads_to_save.push((
1526                format!("b-session-{i}"),
1527                format!("Thread B{i}"),
1528                project_b_paths.clone(),
1529                now + chrono::Duration::seconds(i as i64),
1530            ));
1531        }
1532
1533        for (session_id, title, paths, updated_at) in &threads_to_save {
1534            let save_task = cx.update(|cx| {
1535                let thread_store = ThreadStore::global(cx);
1536                let session_id = session_id.to_string();
1537                let title = title.to_string();
1538                let paths = paths.clone();
1539                thread_store.update(cx, |store, cx| {
1540                    store.save_thread(
1541                        acp::SessionId::new(session_id),
1542                        make_db_thread(&title, *updated_at),
1543                        paths,
1544                        cx,
1545                    )
1546                })
1547            });
1548            save_task.await.unwrap();
1549            cx.run_until_parked();
1550        }
1551
1552        cx.update(|cx| migrate_thread_metadata(cx));
1553        cx.run_until_parked();
1554
1555        let list = cx.update(|cx| {
1556            let store = ThreadMetadataStore::global(cx);
1557            store.read(cx).entries().cloned().collect::<Vec<_>>()
1558        });
1559
1560        assert_eq!(list.len(), 10);
1561
1562        // Project A: 5 most recent should be unarchived, 2 oldest should be archived
1563        let mut project_a_entries: Vec<_> = list
1564            .iter()
1565            .filter(|m| m.folder_paths == project_a_paths)
1566            .collect();
1567        assert_eq!(project_a_entries.len(), 7);
1568        project_a_entries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
1569
1570        for entry in &project_a_entries[..5] {
1571            assert!(
1572                !entry.archived,
1573                "Expected {} to be unarchived (top 5 most recent)",
1574                entry.session_id.0
1575            );
1576        }
1577        for entry in &project_a_entries[5..] {
1578            assert!(
1579                entry.archived,
1580                "Expected {} to be archived (older than top 5)",
1581                entry.session_id.0
1582            );
1583        }
1584
1585        // Project B: all 3 should be unarchived (under the limit)
1586        let project_b_entries: Vec<_> = list
1587            .iter()
1588            .filter(|m| m.folder_paths == project_b_paths)
1589            .collect();
1590        assert_eq!(project_b_entries.len(), 3);
1591        assert!(project_b_entries.iter().all(|m| !m.archived));
1592    }
1593
1594    #[gpui::test]
1595    async fn test_empty_thread_events_do_not_create_metadata(cx: &mut TestAppContext) {
1596        init_test(cx);
1597
1598        let fs = FakeFs::new(cx.executor());
1599        let project = Project::test(fs, None::<&Path>, cx).await;
1600        let connection = Rc::new(StubAgentConnection::new());
1601
1602        let thread = cx
1603            .update(|cx| {
1604                connection
1605                    .clone()
1606                    .new_session(project.clone(), PathList::default(), cx)
1607            })
1608            .await
1609            .unwrap();
1610        let session_id = cx.read(|cx| thread.read(cx).session_id().clone());
1611
1612        cx.update(|cx| {
1613            thread.update(cx, |thread, cx| {
1614                thread.set_title("Draft Thread".into(), cx).detach();
1615            });
1616        });
1617        cx.run_until_parked();
1618
1619        let metadata_ids = cx.update(|cx| {
1620            ThreadMetadataStore::global(cx)
1621                .read(cx)
1622                .entry_ids()
1623                .collect::<Vec<_>>()
1624        });
1625        assert!(
1626            metadata_ids.is_empty(),
1627            "expected empty draft thread title updates to be ignored"
1628        );
1629
1630        cx.update(|cx| {
1631            thread.update(cx, |thread, cx| {
1632                thread.push_user_content_block(None, "Hello".into(), cx);
1633            });
1634        });
1635        cx.run_until_parked();
1636
1637        let metadata_ids = cx.update(|cx| {
1638            ThreadMetadataStore::global(cx)
1639                .read(cx)
1640                .entry_ids()
1641                .collect::<Vec<_>>()
1642        });
1643        assert_eq!(metadata_ids, vec![session_id]);
1644    }
1645
1646    #[gpui::test]
1647    async fn test_nonempty_thread_metadata_preserved_when_thread_released(cx: &mut TestAppContext) {
1648        init_test(cx);
1649
1650        let fs = FakeFs::new(cx.executor());
1651        let project = Project::test(fs, None::<&Path>, cx).await;
1652        let connection = Rc::new(StubAgentConnection::new());
1653
1654        let thread = cx
1655            .update(|cx| {
1656                connection
1657                    .clone()
1658                    .new_session(project.clone(), PathList::default(), cx)
1659            })
1660            .await
1661            .unwrap();
1662        let session_id = cx.read(|cx| thread.read(cx).session_id().clone());
1663
1664        cx.update(|cx| {
1665            thread.update(cx, |thread, cx| {
1666                thread.push_user_content_block(None, "Hello".into(), cx);
1667            });
1668        });
1669        cx.run_until_parked();
1670
1671        let metadata_ids = cx.update(|cx| {
1672            ThreadMetadataStore::global(cx)
1673                .read(cx)
1674                .entry_ids()
1675                .collect::<Vec<_>>()
1676        });
1677        assert_eq!(metadata_ids, vec![session_id.clone()]);
1678
1679        drop(thread);
1680        cx.update(|_| {});
1681        cx.run_until_parked();
1682
1683        let metadata_ids = cx.update(|cx| {
1684            ThreadMetadataStore::global(cx)
1685                .read(cx)
1686                .entry_ids()
1687                .collect::<Vec<_>>()
1688        });
1689        assert_eq!(metadata_ids, vec![session_id]);
1690    }
1691
1692    #[gpui::test]
1693    async fn test_threads_without_project_association_are_archived_by_default(
1694        cx: &mut TestAppContext,
1695    ) {
1696        init_test(cx);
1697
1698        let fs = FakeFs::new(cx.executor());
1699        let project_without_worktree = Project::test(fs.clone(), None::<&Path>, cx).await;
1700        let project_with_worktree = Project::test(fs, [Path::new("/project-a")], cx).await;
1701        let connection = Rc::new(StubAgentConnection::new());
1702
1703        let thread_without_worktree = cx
1704            .update(|cx| {
1705                connection.clone().new_session(
1706                    project_without_worktree.clone(),
1707                    PathList::default(),
1708                    cx,
1709                )
1710            })
1711            .await
1712            .unwrap();
1713        let session_without_worktree =
1714            cx.read(|cx| thread_without_worktree.read(cx).session_id().clone());
1715
1716        cx.update(|cx| {
1717            thread_without_worktree.update(cx, |thread, cx| {
1718                thread.push_user_content_block(None, "content".into(), cx);
1719                thread.set_title("No Project Thread".into(), cx).detach();
1720            });
1721        });
1722        cx.run_until_parked();
1723
1724        let thread_with_worktree = cx
1725            .update(|cx| {
1726                connection.clone().new_session(
1727                    project_with_worktree.clone(),
1728                    PathList::default(),
1729                    cx,
1730                )
1731            })
1732            .await
1733            .unwrap();
1734        let session_with_worktree =
1735            cx.read(|cx| thread_with_worktree.read(cx).session_id().clone());
1736
1737        cx.update(|cx| {
1738            thread_with_worktree.update(cx, |thread, cx| {
1739                thread.push_user_content_block(None, "content".into(), cx);
1740                thread.set_title("Project Thread".into(), cx).detach();
1741            });
1742        });
1743        cx.run_until_parked();
1744
1745        cx.update(|cx| {
1746            let store = ThreadMetadataStore::global(cx);
1747            let store = store.read(cx);
1748
1749            let without_worktree = store
1750                .entry(&session_without_worktree)
1751                .expect("missing metadata for thread without project association");
1752            assert!(without_worktree.folder_paths.is_empty());
1753            assert!(
1754                without_worktree.archived,
1755                "expected thread without project association to be archived"
1756            );
1757
1758            let with_worktree = store
1759                .entry(&session_with_worktree)
1760                .expect("missing metadata for thread with project association");
1761            assert_eq!(
1762                with_worktree.folder_paths,
1763                PathList::new(&[Path::new("/project-a")])
1764            );
1765            assert!(
1766                !with_worktree.archived,
1767                "expected thread with project association to remain unarchived"
1768            );
1769        });
1770    }
1771
1772    #[gpui::test]
1773    async fn test_subagent_threads_excluded_from_sidebar_metadata(cx: &mut TestAppContext) {
1774        init_test(cx);
1775
1776        let fs = FakeFs::new(cx.executor());
1777        let project = Project::test(fs, None::<&Path>, cx).await;
1778        let connection = Rc::new(StubAgentConnection::new());
1779
1780        // Create a regular (non-subagent) AcpThread.
1781        let regular_thread = cx
1782            .update(|cx| {
1783                connection
1784                    .clone()
1785                    .new_session(project.clone(), PathList::default(), cx)
1786            })
1787            .await
1788            .unwrap();
1789
1790        let regular_session_id = cx.read(|cx| regular_thread.read(cx).session_id().clone());
1791
1792        // Set a title on the regular thread to trigger a save via handle_thread_update.
1793        cx.update(|cx| {
1794            regular_thread.update(cx, |thread, cx| {
1795                thread.push_user_content_block(None, "content".into(), cx);
1796                thread.set_title("Regular Thread".into(), cx).detach();
1797            });
1798        });
1799        cx.run_until_parked();
1800
1801        // Create a subagent AcpThread
1802        let subagent_session_id = acp::SessionId::new("subagent-session");
1803        let subagent_thread = cx.update(|cx| {
1804            let action_log = cx.new(|_| ActionLog::new(project.clone()));
1805            cx.new(|cx| {
1806                acp_thread::AcpThread::new(
1807                    Some(regular_session_id.clone()),
1808                    Some("Subagent Thread".into()),
1809                    None,
1810                    connection.clone(),
1811                    project.clone(),
1812                    action_log,
1813                    subagent_session_id.clone(),
1814                    watch::Receiver::constant(acp::PromptCapabilities::new()),
1815                    cx,
1816                )
1817            })
1818        });
1819
1820        // Set a title on the subagent thread to trigger handle_thread_update.
1821        cx.update(|cx| {
1822            subagent_thread.update(cx, |thread, cx| {
1823                thread
1824                    .set_title("Subagent Thread Title".into(), cx)
1825                    .detach();
1826            });
1827        });
1828        cx.run_until_parked();
1829
1830        // List all metadata from the store cache.
1831        let list = cx.update(|cx| {
1832            let store = ThreadMetadataStore::global(cx);
1833            store.read(cx).entries().cloned().collect::<Vec<_>>()
1834        });
1835
1836        // The subagent thread should NOT appear in the sidebar metadata.
1837        // Only the regular thread should be listed.
1838        assert_eq!(
1839            list.len(),
1840            1,
1841            "Expected only the regular thread in sidebar metadata, \
1842             but found {} entries (subagent threads are leaking into the sidebar)",
1843            list.len(),
1844        );
1845        assert_eq!(list[0].session_id, regular_session_id);
1846        assert_eq!(list[0].title.as_ref(), "Regular Thread");
1847    }
1848
1849    #[test]
1850    fn test_dedup_db_operations_keeps_latest_operation_for_session() {
1851        let now = Utc::now();
1852
1853        let operations = vec![
1854            DbOperation::Upsert(make_metadata(
1855                "session-1",
1856                "First Thread",
1857                now,
1858                PathList::default(),
1859            )),
1860            DbOperation::Delete(acp::SessionId::new("session-1")),
1861        ];
1862
1863        let deduped = ThreadMetadataStore::dedup_db_operations(operations);
1864
1865        assert_eq!(deduped.len(), 1);
1866        assert_eq!(
1867            deduped[0],
1868            DbOperation::Delete(acp::SessionId::new("session-1"))
1869        );
1870    }
1871
1872    #[test]
1873    fn test_dedup_db_operations_keeps_latest_insert_for_same_session() {
1874        let now = Utc::now();
1875        let later = now + chrono::Duration::seconds(1);
1876
1877        let old_metadata = make_metadata("session-1", "Old Title", now, PathList::default());
1878        let new_metadata = make_metadata("session-1", "New Title", later, PathList::default());
1879
1880        let deduped = ThreadMetadataStore::dedup_db_operations(vec![
1881            DbOperation::Upsert(old_metadata),
1882            DbOperation::Upsert(new_metadata.clone()),
1883        ]);
1884
1885        assert_eq!(deduped.len(), 1);
1886        assert_eq!(deduped[0], DbOperation::Upsert(new_metadata));
1887    }
1888
1889    #[test]
1890    fn test_dedup_db_operations_preserves_distinct_sessions() {
1891        let now = Utc::now();
1892
1893        let metadata1 = make_metadata("session-1", "First Thread", now, PathList::default());
1894        let metadata2 = make_metadata("session-2", "Second Thread", now, PathList::default());
1895        let deduped = ThreadMetadataStore::dedup_db_operations(vec![
1896            DbOperation::Upsert(metadata1.clone()),
1897            DbOperation::Upsert(metadata2.clone()),
1898        ]);
1899
1900        assert_eq!(deduped.len(), 2);
1901        assert!(deduped.contains(&DbOperation::Upsert(metadata1)));
1902        assert!(deduped.contains(&DbOperation::Upsert(metadata2)));
1903    }
1904
1905    #[gpui::test]
1906    async fn test_archive_and_unarchive_thread(cx: &mut TestAppContext) {
1907        init_test(cx);
1908
1909        let paths = PathList::new(&[Path::new("/project-a")]);
1910        let now = Utc::now();
1911        let metadata = make_metadata("session-1", "Thread 1", now, paths.clone());
1912
1913        cx.update(|cx| {
1914            let store = ThreadMetadataStore::global(cx);
1915            store.update(cx, |store, cx| {
1916                store.save(metadata, cx);
1917            });
1918        });
1919
1920        cx.run_until_parked();
1921
1922        cx.update(|cx| {
1923            let store = ThreadMetadataStore::global(cx);
1924            let store = store.read(cx);
1925
1926            let path_entries = store
1927                .entries_for_path(&paths)
1928                .map(|e| e.session_id.0.to_string())
1929                .collect::<Vec<_>>();
1930            assert_eq!(path_entries, vec!["session-1"]);
1931
1932            let archived = store
1933                .archived_entries()
1934                .map(|e| e.session_id.0.to_string())
1935                .collect::<Vec<_>>();
1936            assert!(archived.is_empty());
1937        });
1938
1939        cx.update(|cx| {
1940            let store = ThreadMetadataStore::global(cx);
1941            store.update(cx, |store, cx| {
1942                store.archive(&acp::SessionId::new("session-1"), cx);
1943            });
1944        });
1945
1946        cx.run_until_parked();
1947
1948        cx.update(|cx| {
1949            let store = ThreadMetadataStore::global(cx);
1950            let store = store.read(cx);
1951
1952            let path_entries = store
1953                .entries_for_path(&paths)
1954                .map(|e| e.session_id.0.to_string())
1955                .collect::<Vec<_>>();
1956            assert!(path_entries.is_empty());
1957
1958            let archived = store.archived_entries().collect::<Vec<_>>();
1959            assert_eq!(archived.len(), 1);
1960            assert_eq!(archived[0].session_id.0.as_ref(), "session-1");
1961            assert!(archived[0].archived);
1962        });
1963
1964        cx.update(|cx| {
1965            let store = ThreadMetadataStore::global(cx);
1966            store.update(cx, |store, cx| {
1967                store.unarchive(&acp::SessionId::new("session-1"), cx);
1968            });
1969        });
1970
1971        cx.run_until_parked();
1972
1973        cx.update(|cx| {
1974            let store = ThreadMetadataStore::global(cx);
1975            let store = store.read(cx);
1976
1977            let path_entries = store
1978                .entries_for_path(&paths)
1979                .map(|e| e.session_id.0.to_string())
1980                .collect::<Vec<_>>();
1981            assert_eq!(path_entries, vec!["session-1"]);
1982
1983            let archived = store
1984                .archived_entries()
1985                .map(|e| e.session_id.0.to_string())
1986                .collect::<Vec<_>>();
1987            assert!(archived.is_empty());
1988        });
1989    }
1990
1991    #[gpui::test]
1992    async fn test_entries_for_path_excludes_archived(cx: &mut TestAppContext) {
1993        init_test(cx);
1994
1995        let paths = PathList::new(&[Path::new("/project-a")]);
1996        let now = Utc::now();
1997
1998        let metadata1 = make_metadata("session-1", "Active Thread", now, paths.clone());
1999        let metadata2 = make_metadata(
2000            "session-2",
2001            "Archived Thread",
2002            now - chrono::Duration::seconds(1),
2003            paths.clone(),
2004        );
2005
2006        cx.update(|cx| {
2007            let store = ThreadMetadataStore::global(cx);
2008            store.update(cx, |store, cx| {
2009                store.save(metadata1, cx);
2010                store.save(metadata2, cx);
2011            });
2012        });
2013
2014        cx.run_until_parked();
2015
2016        cx.update(|cx| {
2017            let store = ThreadMetadataStore::global(cx);
2018            store.update(cx, |store, cx| {
2019                store.archive(&acp::SessionId::new("session-2"), cx);
2020            });
2021        });
2022
2023        cx.run_until_parked();
2024
2025        cx.update(|cx| {
2026            let store = ThreadMetadataStore::global(cx);
2027            let store = store.read(cx);
2028
2029            let path_entries = store
2030                .entries_for_path(&paths)
2031                .map(|e| e.session_id.0.to_string())
2032                .collect::<Vec<_>>();
2033            assert_eq!(path_entries, vec!["session-1"]);
2034
2035            let all_entries = store
2036                .entries()
2037                .map(|e| e.session_id.0.to_string())
2038                .collect::<Vec<_>>();
2039            assert_eq!(all_entries.len(), 2);
2040            assert!(all_entries.contains(&"session-1".to_string()));
2041            assert!(all_entries.contains(&"session-2".to_string()));
2042
2043            let archived = store
2044                .archived_entries()
2045                .map(|e| e.session_id.0.to_string())
2046                .collect::<Vec<_>>();
2047            assert_eq!(archived, vec!["session-2"]);
2048        });
2049    }
2050
2051    #[gpui::test]
2052    async fn test_save_all_persists_multiple_threads(cx: &mut TestAppContext) {
2053        init_test(cx);
2054
2055        let paths = PathList::new(&[Path::new("/project-a")]);
2056        let now = Utc::now();
2057
2058        let m1 = make_metadata("session-1", "Thread One", now, paths.clone());
2059        let m2 = make_metadata(
2060            "session-2",
2061            "Thread Two",
2062            now - chrono::Duration::seconds(1),
2063            paths.clone(),
2064        );
2065        let m3 = make_metadata(
2066            "session-3",
2067            "Thread Three",
2068            now - chrono::Duration::seconds(2),
2069            paths,
2070        );
2071
2072        cx.update(|cx| {
2073            let store = ThreadMetadataStore::global(cx);
2074            store.update(cx, |store, cx| {
2075                store.save_all(vec![m1, m2, m3], cx);
2076            });
2077        });
2078
2079        cx.run_until_parked();
2080
2081        cx.update(|cx| {
2082            let store = ThreadMetadataStore::global(cx);
2083            let store = store.read(cx);
2084
2085            let all_entries = store
2086                .entries()
2087                .map(|e| e.session_id.0.to_string())
2088                .collect::<Vec<_>>();
2089            assert_eq!(all_entries.len(), 3);
2090            assert!(all_entries.contains(&"session-1".to_string()));
2091            assert!(all_entries.contains(&"session-2".to_string()));
2092            assert!(all_entries.contains(&"session-3".to_string()));
2093
2094            let entry_ids = store.entry_ids().collect::<Vec<_>>();
2095            assert_eq!(entry_ids.len(), 3);
2096        });
2097    }
2098
2099    #[gpui::test]
2100    async fn test_archived_flag_persists_across_reload(cx: &mut TestAppContext) {
2101        init_test(cx);
2102
2103        let paths = PathList::new(&[Path::new("/project-a")]);
2104        let now = Utc::now();
2105        let metadata = make_metadata("session-1", "Thread 1", now, paths.clone());
2106
2107        cx.update(|cx| {
2108            let store = ThreadMetadataStore::global(cx);
2109            store.update(cx, |store, cx| {
2110                store.save(metadata, cx);
2111            });
2112        });
2113
2114        cx.run_until_parked();
2115
2116        cx.update(|cx| {
2117            let store = ThreadMetadataStore::global(cx);
2118            store.update(cx, |store, cx| {
2119                store.archive(&acp::SessionId::new("session-1"), cx);
2120            });
2121        });
2122
2123        cx.run_until_parked();
2124
2125        cx.update(|cx| {
2126            let store = ThreadMetadataStore::global(cx);
2127            store.update(cx, |store, cx| {
2128                let _ = store.reload(cx);
2129            });
2130        });
2131
2132        cx.run_until_parked();
2133
2134        cx.update(|cx| {
2135            let store = ThreadMetadataStore::global(cx);
2136            let store = store.read(cx);
2137
2138            let thread = store
2139                .entries()
2140                .find(|e| e.session_id.0.as_ref() == "session-1")
2141                .expect("thread should exist after reload");
2142            assert!(thread.archived);
2143
2144            let path_entries = store
2145                .entries_for_path(&paths)
2146                .map(|e| e.session_id.0.to_string())
2147                .collect::<Vec<_>>();
2148            assert!(path_entries.is_empty());
2149
2150            let archived = store
2151                .archived_entries()
2152                .map(|e| e.session_id.0.to_string())
2153                .collect::<Vec<_>>();
2154            assert_eq!(archived, vec!["session-1"]);
2155        });
2156    }
2157
2158    #[gpui::test]
2159    async fn test_archive_nonexistent_thread_is_noop(cx: &mut TestAppContext) {
2160        init_test(cx);
2161
2162        cx.run_until_parked();
2163
2164        cx.update(|cx| {
2165            let store = ThreadMetadataStore::global(cx);
2166            store.update(cx, |store, cx| {
2167                store.archive(&acp::SessionId::new("nonexistent"), cx);
2168            });
2169        });
2170
2171        cx.run_until_parked();
2172
2173        cx.update(|cx| {
2174            let store = ThreadMetadataStore::global(cx);
2175            let store = store.read(cx);
2176
2177            assert!(store.is_empty());
2178            assert_eq!(store.entries().count(), 0);
2179            assert_eq!(store.archived_entries().count(), 0);
2180        });
2181    }
2182
2183    #[gpui::test]
2184    async fn test_save_followed_by_archiving_without_parking(cx: &mut TestAppContext) {
2185        init_test(cx);
2186
2187        let paths = PathList::new(&[Path::new("/project-a")]);
2188        let now = Utc::now();
2189        let metadata = make_metadata("session-1", "Thread 1", now, paths);
2190        let session_id = metadata.session_id.clone();
2191
2192        cx.update(|cx| {
2193            let store = ThreadMetadataStore::global(cx);
2194            store.update(cx, |store, cx| {
2195                store.save(metadata.clone(), cx);
2196                store.archive(&session_id, cx);
2197            });
2198        });
2199
2200        cx.run_until_parked();
2201
2202        cx.update(|cx| {
2203            let store = ThreadMetadataStore::global(cx);
2204            let store = store.read(cx);
2205
2206            let entries: Vec<ThreadMetadata> = store.entries().cloned().collect();
2207            pretty_assertions::assert_eq!(
2208                entries,
2209                vec![ThreadMetadata {
2210                    archived: true,
2211                    ..metadata
2212                }]
2213            );
2214        });
2215    }
2216
2217    #[gpui::test]
2218    async fn test_create_and_retrieve_archived_worktree(cx: &mut TestAppContext) {
2219        init_test(cx);
2220        let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2221
2222        let id = store
2223            .read_with(cx, |store, cx| {
2224                store.create_archived_worktree(
2225                    "/tmp/worktree",
2226                    "/home/user/repo",
2227                    Some("feature-branch"),
2228                    "abc123def456",
2229                    "abc123def456",
2230                    cx,
2231                )
2232            })
2233            .await
2234            .unwrap();
2235
2236        store
2237            .read_with(cx, |store, cx| {
2238                store.link_thread_to_archived_worktree("session-1", id, cx)
2239            })
2240            .await
2241            .unwrap();
2242
2243        let worktrees = store
2244            .read_with(cx, |store, cx| {
2245                store.get_archived_worktrees_for_thread("session-1", cx)
2246            })
2247            .await
2248            .unwrap();
2249
2250        assert_eq!(worktrees.len(), 1);
2251        let wt = &worktrees[0];
2252        assert_eq!(wt.id, id);
2253        assert_eq!(wt.worktree_path, PathBuf::from("/tmp/worktree"));
2254        assert_eq!(wt.main_repo_path, PathBuf::from("/home/user/repo"));
2255        assert_eq!(wt.branch_name.as_deref(), Some("feature-branch"));
2256        assert_eq!(wt.staged_commit_hash, "abc123def456");
2257        assert_eq!(wt.unstaged_commit_hash, "abc123def456");
2258        assert!(!wt.restored);
2259    }
2260
2261    #[gpui::test]
2262    async fn test_delete_archived_worktree(cx: &mut TestAppContext) {
2263        init_test(cx);
2264        let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2265
2266        let id = store
2267            .read_with(cx, |store, cx| {
2268                store.create_archived_worktree(
2269                    "/tmp/worktree",
2270                    "/home/user/repo",
2271                    Some("main"),
2272                    "deadbeef",
2273                    "deadbeef",
2274                    cx,
2275                )
2276            })
2277            .await
2278            .unwrap();
2279
2280        store
2281            .read_with(cx, |store, cx| {
2282                store.link_thread_to_archived_worktree("session-1", id, cx)
2283            })
2284            .await
2285            .unwrap();
2286
2287        store
2288            .read_with(cx, |store, cx| store.delete_archived_worktree(id, cx))
2289            .await
2290            .unwrap();
2291
2292        let worktrees = store
2293            .read_with(cx, |store, cx| {
2294                store.get_archived_worktrees_for_thread("session-1", cx)
2295            })
2296            .await
2297            .unwrap();
2298        assert!(worktrees.is_empty());
2299    }
2300
2301    #[gpui::test]
2302    async fn test_set_archived_worktree_restored(cx: &mut TestAppContext) {
2303        init_test(cx);
2304        let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2305
2306        let id = store
2307            .read_with(cx, |store, cx| {
2308                store.create_archived_worktree(
2309                    "/tmp/old-worktree",
2310                    "/home/user/repo",
2311                    Some("old-branch"),
2312                    "abc123",
2313                    "abc123",
2314                    cx,
2315                )
2316            })
2317            .await
2318            .unwrap();
2319
2320        store
2321            .read_with(cx, |store, cx| {
2322                store.set_archived_worktree_restored(
2323                    id,
2324                    "/tmp/new-worktree",
2325                    Some("new-branch"),
2326                    cx,
2327                )
2328            })
2329            .await
2330            .unwrap();
2331
2332        store
2333            .read_with(cx, |store, cx| {
2334                store.link_thread_to_archived_worktree("session-1", id, cx)
2335            })
2336            .await
2337            .unwrap();
2338
2339        let worktrees = store
2340            .read_with(cx, |store, cx| {
2341                store.get_archived_worktrees_for_thread("session-1", cx)
2342            })
2343            .await
2344            .unwrap();
2345
2346        assert_eq!(worktrees.len(), 1);
2347        let wt = &worktrees[0];
2348        assert!(wt.restored);
2349        assert_eq!(wt.worktree_path, PathBuf::from("/tmp/new-worktree"));
2350        assert_eq!(wt.branch_name.as_deref(), Some("new-branch"));
2351    }
2352
2353    #[gpui::test]
2354    async fn test_link_multiple_threads_to_archived_worktree(cx: &mut TestAppContext) {
2355        init_test(cx);
2356        let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2357
2358        let id = store
2359            .read_with(cx, |store, cx| {
2360                store.create_archived_worktree(
2361                    "/tmp/worktree",
2362                    "/home/user/repo",
2363                    None,
2364                    "abc123",
2365                    "abc123",
2366                    cx,
2367                )
2368            })
2369            .await
2370            .unwrap();
2371
2372        store
2373            .read_with(cx, |store, cx| {
2374                store.link_thread_to_archived_worktree("session-1", id, cx)
2375            })
2376            .await
2377            .unwrap();
2378
2379        store
2380            .read_with(cx, |store, cx| {
2381                store.link_thread_to_archived_worktree("session-2", id, cx)
2382            })
2383            .await
2384            .unwrap();
2385
2386        let wt1 = store
2387            .read_with(cx, |store, cx| {
2388                store.get_archived_worktrees_for_thread("session-1", cx)
2389            })
2390            .await
2391            .unwrap();
2392
2393        let wt2 = store
2394            .read_with(cx, |store, cx| {
2395                store.get_archived_worktrees_for_thread("session-2", cx)
2396            })
2397            .await
2398            .unwrap();
2399
2400        assert_eq!(wt1.len(), 1);
2401        assert_eq!(wt2.len(), 1);
2402        assert_eq!(wt1[0].id, wt2[0].id);
2403    }
2404
2405    #[gpui::test]
2406    async fn test_all_session_ids_for_path(cx: &mut TestAppContext) {
2407        init_test(cx);
2408        let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2409        let paths = PathList::new(&[Path::new("/project-x")]);
2410
2411        let meta1 = ThreadMetadata {
2412            session_id: acp::SessionId::new("session-1"),
2413            agent_id: agent::ZED_AGENT_ID.clone(),
2414            title: "Thread 1".into(),
2415            updated_at: Utc::now(),
2416            created_at: Some(Utc::now()),
2417            folder_paths: paths.clone(),
2418            main_worktree_paths: PathList::default(),
2419            archived: false,
2420            pending_worktree_restore: None,
2421        };
2422        let meta2 = ThreadMetadata {
2423            session_id: acp::SessionId::new("session-2"),
2424            agent_id: agent::ZED_AGENT_ID.clone(),
2425            title: "Thread 2".into(),
2426            updated_at: Utc::now(),
2427            created_at: Some(Utc::now()),
2428            folder_paths: paths.clone(),
2429            main_worktree_paths: PathList::default(),
2430            archived: true,
2431            pending_worktree_restore: None,
2432        };
2433
2434        store.update(cx, |store, _cx| {
2435            store.save_internal(meta1);
2436            store.save_internal(meta2);
2437        });
2438
2439        let ids: HashSet<acp::SessionId> = store.read_with(cx, |store, _cx| {
2440            store.all_session_ids_for_path(&paths).cloned().collect()
2441        });
2442
2443        assert!(ids.contains(&acp::SessionId::new("session-1")));
2444        assert!(ids.contains(&acp::SessionId::new("session-2")));
2445        assert_eq!(ids.len(), 2);
2446    }
2447
2448    #[gpui::test]
2449    async fn test_two_sha_round_trip(cx: &mut TestAppContext) {
2450        init_test(cx);
2451        let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2452
2453        let id = store
2454            .read_with(cx, |store, cx| {
2455                store.create_archived_worktree(
2456                    "/tmp/worktree",
2457                    "/home/user/repo",
2458                    Some("feature"),
2459                    "staged_sha_aaa",
2460                    "unstaged_sha_bbb",
2461                    cx,
2462                )
2463            })
2464            .await
2465            .unwrap();
2466
2467        store
2468            .read_with(cx, |store, cx| {
2469                store.link_thread_to_archived_worktree("session-1", id, cx)
2470            })
2471            .await
2472            .unwrap();
2473
2474        let worktrees = store
2475            .read_with(cx, |store, cx| {
2476                store.get_archived_worktrees_for_thread("session-1", cx)
2477            })
2478            .await
2479            .unwrap();
2480
2481        assert_eq!(worktrees.len(), 1);
2482        let wt = &worktrees[0];
2483        assert_eq!(wt.staged_commit_hash, "staged_sha_aaa");
2484        assert_eq!(wt.unstaged_commit_hash, "unstaged_sha_bbb");
2485        assert_eq!(wt.branch_name.as_deref(), Some("feature"));
2486        assert!(!wt.restored);
2487    }
2488
2489    #[gpui::test]
2490    async fn test_complete_worktree_restore_single_path(cx: &mut TestAppContext) {
2491        init_test(cx);
2492        let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2493
2494        let original_paths = PathList::new(&[Path::new("/projects/worktree-a")]);
2495        let meta = make_metadata("session-1", "Thread 1", Utc::now(), original_paths);
2496
2497        store.update(cx, |store, cx| {
2498            store.save_manually(meta, cx);
2499        });
2500
2501        let replacements = vec![(
2502            PathBuf::from("/projects/worktree-a"),
2503            PathBuf::from("/projects/worktree-a-restored"),
2504        )];
2505
2506        store.update(cx, |store, cx| {
2507            store.complete_worktree_restore(&acp::SessionId::new("session-1"), &replacements, cx);
2508        });
2509
2510        let entry = store.read_with(cx, |store, _cx| {
2511            store.entry(&acp::SessionId::new("session-1")).cloned()
2512        });
2513        let entry = entry.unwrap();
2514        assert!(entry.pending_worktree_restore.is_none());
2515        assert_eq!(
2516            entry.folder_paths.paths(),
2517            &[PathBuf::from("/projects/worktree-a-restored")]
2518        );
2519    }
2520
2521    #[gpui::test]
2522    async fn test_complete_worktree_restore_multiple_paths(cx: &mut TestAppContext) {
2523        init_test(cx);
2524        let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2525
2526        let original_paths = PathList::new(&[
2527            Path::new("/projects/worktree-a"),
2528            Path::new("/projects/worktree-b"),
2529            Path::new("/other/unrelated"),
2530        ]);
2531        let meta = make_metadata("session-multi", "Multi Thread", Utc::now(), original_paths);
2532
2533        store.update(cx, |store, cx| {
2534            store.save_manually(meta, cx);
2535        });
2536
2537        let replacements = vec![
2538            (
2539                PathBuf::from("/projects/worktree-a"),
2540                PathBuf::from("/restored/worktree-a"),
2541            ),
2542            (
2543                PathBuf::from("/projects/worktree-b"),
2544                PathBuf::from("/restored/worktree-b"),
2545            ),
2546        ];
2547
2548        store.update(cx, |store, cx| {
2549            store.complete_worktree_restore(
2550                &acp::SessionId::new("session-multi"),
2551                &replacements,
2552                cx,
2553            );
2554        });
2555
2556        let entry = store.read_with(cx, |store, _cx| {
2557            store.entry(&acp::SessionId::new("session-multi")).cloned()
2558        });
2559        let entry = entry.unwrap();
2560        assert!(entry.pending_worktree_restore.is_none());
2561
2562        let paths = entry.folder_paths.paths();
2563        assert_eq!(paths.len(), 3);
2564        assert!(paths.contains(&PathBuf::from("/restored/worktree-a")));
2565        assert!(paths.contains(&PathBuf::from("/restored/worktree-b")));
2566        assert!(paths.contains(&PathBuf::from("/other/unrelated")));
2567    }
2568
2569    #[gpui::test]
2570    async fn test_complete_worktree_restore_preserves_unmatched_paths(cx: &mut TestAppContext) {
2571        init_test(cx);
2572        let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2573
2574        let original_paths =
2575            PathList::new(&[Path::new("/projects/worktree-a"), Path::new("/other/path")]);
2576        let meta = make_metadata("session-partial", "Partial", Utc::now(), original_paths);
2577
2578        store.update(cx, |store, cx| {
2579            store.save_manually(meta, cx);
2580        });
2581
2582        let replacements = vec![
2583            (
2584                PathBuf::from("/projects/worktree-a"),
2585                PathBuf::from("/new/worktree-a"),
2586            ),
2587            (
2588                PathBuf::from("/nonexistent/path"),
2589                PathBuf::from("/should/not/appear"),
2590            ),
2591        ];
2592
2593        store.update(cx, |store, cx| {
2594            store.complete_worktree_restore(
2595                &acp::SessionId::new("session-partial"),
2596                &replacements,
2597                cx,
2598            );
2599        });
2600
2601        let entry = store.read_with(cx, |store, _cx| {
2602            store
2603                .entry(&acp::SessionId::new("session-partial"))
2604                .cloned()
2605        });
2606        let entry = entry.unwrap();
2607        let paths = entry.folder_paths.paths();
2608        assert_eq!(paths.len(), 2);
2609        assert!(paths.contains(&PathBuf::from("/new/worktree-a")));
2610        assert!(paths.contains(&PathBuf::from("/other/path")));
2611        assert!(!paths.contains(&PathBuf::from("/should/not/appear")));
2612    }
2613
2614    #[gpui::test]
2615    async fn test_multiple_archived_worktrees_per_thread(cx: &mut TestAppContext) {
2616        init_test(cx);
2617        let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2618
2619        let id1 = store
2620            .read_with(cx, |store, cx| {
2621                store.create_archived_worktree(
2622                    "/projects/worktree-a",
2623                    "/home/user/repo",
2624                    Some("branch-a"),
2625                    "staged_a",
2626                    "unstaged_a",
2627                    cx,
2628                )
2629            })
2630            .await
2631            .unwrap();
2632
2633        let id2 = store
2634            .read_with(cx, |store, cx| {
2635                store.create_archived_worktree(
2636                    "/projects/worktree-b",
2637                    "/home/user/repo",
2638                    Some("branch-b"),
2639                    "staged_b",
2640                    "unstaged_b",
2641                    cx,
2642                )
2643            })
2644            .await
2645            .unwrap();
2646
2647        store
2648            .read_with(cx, |store, cx| {
2649                store.link_thread_to_archived_worktree("session-1", id1, cx)
2650            })
2651            .await
2652            .unwrap();
2653
2654        store
2655            .read_with(cx, |store, cx| {
2656                store.link_thread_to_archived_worktree("session-1", id2, cx)
2657            })
2658            .await
2659            .unwrap();
2660
2661        let worktrees = store
2662            .read_with(cx, |store, cx| {
2663                store.get_archived_worktrees_for_thread("session-1", cx)
2664            })
2665            .await
2666            .unwrap();
2667
2668        assert_eq!(worktrees.len(), 2);
2669
2670        let wt_a = worktrees
2671            .iter()
2672            .find(|w| w.worktree_path.as_path() == Path::new("/projects/worktree-a"))
2673            .unwrap();
2674        assert_eq!(wt_a.staged_commit_hash, "staged_a");
2675        assert_eq!(wt_a.unstaged_commit_hash, "unstaged_a");
2676        assert_eq!(wt_a.branch_name.as_deref(), Some("branch-a"));
2677
2678        let wt_b = worktrees
2679            .iter()
2680            .find(|w| w.worktree_path.as_path() == Path::new("/projects/worktree-b"))
2681            .unwrap();
2682        assert_eq!(wt_b.staged_commit_hash, "staged_b");
2683        assert_eq!(wt_b.unstaged_commit_hash, "unstaged_b");
2684        assert_eq!(wt_b.branch_name.as_deref(), Some("branch-b"));
2685    }
2686}