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 being used temporarily while restoration is pending.
 138    pub pending_worktree_restore: Option<PathBuf>,
 139}
 140
 141impl From<&ThreadMetadata> for acp_thread::AgentSessionInfo {
 142    fn from(meta: &ThreadMetadata) -> Self {
 143        Self {
 144            session_id: meta.session_id.clone(),
 145            work_dirs: Some(meta.folder_paths.clone()),
 146            title: Some(meta.title.clone()),
 147            updated_at: Some(meta.updated_at),
 148            created_at: meta.created_at,
 149            meta: None,
 150        }
 151    }
 152}
 153
 154/// Record of a git worktree that was archived (deleted from disk) when its last thread was archived.
 155pub struct ArchivedGitWorktree {
 156    /// Auto-incrementing primary key
 157    pub id: i64,
 158    /// Absolute path to the directory of the worktree before it was deleted.
 159    /// Used when restoring, to put the recreated worktree back where it was.
 160    /// (If that dir is already taken when restoring, we auto-generate a new dir.)
 161    pub worktree_path: PathBuf,
 162    /// Absolute path of the main repository ("main worktree") that owned this worktree.
 163    /// Used when restoring, to reattach the recreated worktree to the correct main worktree.
 164    /// If the main repo isn't found on disk, unarchiving fails because we only store the
 165    /// commit hash, and without the actual git repo being available, we can't restore the files.
 166    pub main_repo_path: PathBuf,
 167    /// Branch that was checked out in the worktree at archive time. `None` if
 168    /// the worktree was in detached HEAD state, which isn't supported in Zed, but
 169    /// could happen if the user made a detached one outside of Zed.
 170    /// On restore, we try to switch to this branch. If that fails (e.g. it's
 171    /// checked out elsewhere), we try to create it. If both fail, the restored
 172    /// worktree stays in detached HEAD and a warning is logged.
 173    pub branch_name: Option<String>,
 174    /// SHA of the WIP commit that we made in order to save the files that were uncommitted
 175    /// and unstaged at the time of archiving. This commit can be empty. Its parent commit
 176    /// will also be a WIP that we created during archiving, which contains the files that
 177    /// were staged at the time of archiving. (It can also be empty.) After doing `git reset`
 178    /// past both of these commits, we're back in the state we had before archiving, including
 179    /// what was staged, what was unstaged, and what was committed.
 180    pub commit_hash: String,
 181    /// Whether this worktree has been restored by a prior unarchive.
 182    /// Currently unused — the record is deleted immediately after a
 183    /// successful restore rather than being marked as restored.
 184    pub restored: bool,
 185}
 186
 187/// The store holds all metadata needed to show threads in the sidebar/the archive.
 188///
 189/// Automatically listens to AcpThread events and updates metadata if it has changed.
 190pub struct ThreadMetadataStore {
 191    db: ThreadMetadataDb,
 192    threads: HashMap<acp::SessionId, ThreadMetadata>,
 193    threads_by_paths: HashMap<PathList, HashSet<acp::SessionId>>,
 194    threads_by_main_paths: HashMap<PathList, HashSet<acp::SessionId>>,
 195    reload_task: Option<Shared<Task<()>>>,
 196    session_subscriptions: HashMap<acp::SessionId, Subscription>,
 197    pending_thread_ops_tx: smol::channel::Sender<DbOperation>,
 198    _db_operations_task: Task<()>,
 199}
 200
 201#[derive(Debug, PartialEq)]
 202enum DbOperation {
 203    Upsert(ThreadMetadata),
 204    Delete(acp::SessionId),
 205}
 206
 207impl DbOperation {
 208    fn id(&self) -> &acp::SessionId {
 209        match self {
 210            DbOperation::Upsert(thread) => &thread.session_id,
 211            DbOperation::Delete(session_id) => session_id,
 212        }
 213    }
 214}
 215
 216impl ThreadMetadataStore {
 217    #[cfg(not(any(test, feature = "test-support")))]
 218    pub fn init_global(cx: &mut App) {
 219        if cx.has_global::<Self>() {
 220            return;
 221        }
 222
 223        let db = ThreadMetadataDb::global(cx);
 224        let thread_store = cx.new(|cx| Self::new(db, cx));
 225        cx.set_global(GlobalThreadMetadataStore(thread_store));
 226    }
 227
 228    #[cfg(any(test, feature = "test-support"))]
 229    pub fn init_global(cx: &mut App) {
 230        let thread = std::thread::current();
 231        let test_name = thread.name().unwrap_or("unknown_test");
 232        let db_name = format!("THREAD_METADATA_DB_{}", test_name);
 233        let db = smol::block_on(db::open_test_db::<ThreadMetadataDb>(&db_name));
 234        let thread_store = cx.new(|cx| Self::new(ThreadMetadataDb(db), cx));
 235        cx.set_global(GlobalThreadMetadataStore(thread_store));
 236    }
 237
 238    pub fn try_global(cx: &App) -> Option<Entity<Self>> {
 239        cx.try_global::<GlobalThreadMetadataStore>()
 240            .map(|store| store.0.clone())
 241    }
 242
 243    pub fn global(cx: &App) -> Entity<Self> {
 244        cx.global::<GlobalThreadMetadataStore>().0.clone()
 245    }
 246
 247    pub fn is_empty(&self) -> bool {
 248        self.threads.is_empty()
 249    }
 250
 251    /// Returns all thread IDs.
 252    pub fn entry_ids(&self) -> impl Iterator<Item = acp::SessionId> + '_ {
 253        self.threads.keys().cloned()
 254    }
 255
 256    /// Returns the metadata for a specific thread, if it exists.
 257    pub fn entry(&self, session_id: &acp::SessionId) -> Option<&ThreadMetadata> {
 258        self.threads.get(session_id)
 259    }
 260
 261    /// Returns all threads.
 262    pub fn entries(&self) -> impl Iterator<Item = &ThreadMetadata> + '_ {
 263        self.threads.values()
 264    }
 265
 266    /// Returns all archived threads.
 267    pub fn archived_entries(&self) -> impl Iterator<Item = &ThreadMetadata> + '_ {
 268        self.entries().filter(|t| t.archived)
 269    }
 270
 271    /// Returns all session IDs for the given path list, including archived threads.
 272    pub fn all_session_ids_for_path(
 273        &self,
 274        path_list: &PathList,
 275    ) -> impl Iterator<Item = &acp::SessionId> + '_ {
 276        self.threads_by_paths.get(path_list).into_iter().flatten()
 277    }
 278
 279    /// Returns all threads for the given path list, excluding archived threads.
 280    pub fn entries_for_path(
 281        &self,
 282        path_list: &PathList,
 283    ) -> impl Iterator<Item = &ThreadMetadata> + '_ {
 284        self.threads_by_paths
 285            .get(path_list)
 286            .into_iter()
 287            .flatten()
 288            .filter_map(|s| self.threads.get(s))
 289            .filter(|s| !s.archived)
 290    }
 291
 292    /// Returns threads whose `main_worktree_paths` matches the given path list,
 293    /// excluding archived threads. This finds threads that were opened in a
 294    /// linked worktree but are associated with the given main worktree.
 295    pub fn entries_for_main_worktree_path(
 296        &self,
 297        path_list: &PathList,
 298    ) -> impl Iterator<Item = &ThreadMetadata> + '_ {
 299        self.threads_by_main_paths
 300            .get(path_list)
 301            .into_iter()
 302            .flatten()
 303            .filter_map(|s| self.threads.get(s))
 304            .filter(|s| !s.archived)
 305    }
 306
 307    fn reload(&mut self, cx: &mut Context<Self>) -> Shared<Task<()>> {
 308        let db = self.db.clone();
 309        self.reload_task.take();
 310
 311        let list_task = cx
 312            .background_spawn(async move { db.list().context("Failed to fetch sidebar metadata") });
 313
 314        let reload_task = cx
 315            .spawn(async move |this, cx| {
 316                let Some(rows) = list_task.await.log_err() else {
 317                    return;
 318                };
 319
 320                this.update(cx, |this, cx| {
 321                    this.threads.clear();
 322                    this.threads_by_paths.clear();
 323                    this.threads_by_main_paths.clear();
 324
 325                    for row in rows {
 326                        this.threads_by_paths
 327                            .entry(row.folder_paths.clone())
 328                            .or_default()
 329                            .insert(row.session_id.clone());
 330                        if !row.main_worktree_paths.is_empty() {
 331                            this.threads_by_main_paths
 332                                .entry(row.main_worktree_paths.clone())
 333                                .or_default()
 334                                .insert(row.session_id.clone());
 335                        }
 336                        this.threads.insert(row.session_id.clone(), row);
 337                    }
 338
 339                    cx.notify();
 340                })
 341                .ok();
 342            })
 343            .shared();
 344        self.reload_task = Some(reload_task.clone());
 345        reload_task
 346    }
 347
 348    pub fn save_all(&mut self, metadata: Vec<ThreadMetadata>, cx: &mut Context<Self>) {
 349        if !cx.has_flag::<AgentV2FeatureFlag>() {
 350            return;
 351        }
 352
 353        for metadata in metadata {
 354            self.save_internal(metadata);
 355        }
 356        cx.notify();
 357    }
 358
 359    #[cfg(any(test, feature = "test-support"))]
 360    pub fn save_manually(&mut self, metadata: ThreadMetadata, cx: &mut Context<Self>) {
 361        self.save(metadata, cx)
 362    }
 363
 364    fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context<Self>) {
 365        if !cx.has_flag::<AgentV2FeatureFlag>() {
 366            return;
 367        }
 368
 369        self.save_internal(metadata);
 370        cx.notify();
 371    }
 372
 373    fn save_internal(&mut self, metadata: ThreadMetadata) {
 374        if let Some(thread) = self.threads.get(&metadata.session_id) {
 375            if thread.folder_paths != metadata.folder_paths {
 376                if let Some(session_ids) = self.threads_by_paths.get_mut(&thread.folder_paths) {
 377                    session_ids.remove(&metadata.session_id);
 378                }
 379            }
 380            if thread.main_worktree_paths != metadata.main_worktree_paths
 381                && !thread.main_worktree_paths.is_empty()
 382            {
 383                if let Some(session_ids) = self
 384                    .threads_by_main_paths
 385                    .get_mut(&thread.main_worktree_paths)
 386                {
 387                    session_ids.remove(&metadata.session_id);
 388                }
 389            }
 390        }
 391
 392        self.threads
 393            .insert(metadata.session_id.clone(), metadata.clone());
 394
 395        self.threads_by_paths
 396            .entry(metadata.folder_paths.clone())
 397            .or_default()
 398            .insert(metadata.session_id.clone());
 399
 400        if !metadata.main_worktree_paths.is_empty() {
 401            self.threads_by_main_paths
 402                .entry(metadata.main_worktree_paths.clone())
 403                .or_default()
 404                .insert(metadata.session_id.clone());
 405        }
 406
 407        self.pending_thread_ops_tx
 408            .try_send(DbOperation::Upsert(metadata))
 409            .log_err();
 410    }
 411
 412    pub fn update_working_directories(
 413        &mut self,
 414        session_id: &acp::SessionId,
 415        work_dirs: PathList,
 416        cx: &mut Context<Self>,
 417    ) {
 418        if !cx.has_flag::<AgentV2FeatureFlag>() {
 419            return;
 420        }
 421
 422        if let Some(thread) = self.threads.get(session_id) {
 423            self.save_internal(ThreadMetadata {
 424                folder_paths: work_dirs,
 425                ..thread.clone()
 426            });
 427            cx.notify();
 428        }
 429    }
 430
 431    pub fn archive(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
 432        self.update_archived(session_id, true, cx);
 433    }
 434
 435    pub fn unarchive(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
 436        self.update_archived(session_id, false, cx);
 437    }
 438
 439    pub fn create_archived_worktree(
 440        &self,
 441        worktree_path: String,
 442        main_repo_path: String,
 443        branch_name: Option<String>,
 444        commit_hash: String,
 445        cx: &mut Context<Self>,
 446    ) -> Task<anyhow::Result<i64>> {
 447        let db = self.db.clone();
 448        cx.background_spawn(async move {
 449            db.create_archived_worktree(
 450                &worktree_path,
 451                &main_repo_path,
 452                branch_name.as_deref(),
 453                &commit_hash,
 454            )
 455            .await
 456        })
 457    }
 458
 459    pub fn link_thread_to_archived_worktree(
 460        &self,
 461        session_id: String,
 462        archived_worktree_id: i64,
 463        cx: &mut Context<Self>,
 464    ) -> Task<anyhow::Result<()>> {
 465        let db = self.db.clone();
 466        cx.background_spawn(async move {
 467            db.link_thread_to_archived_worktree(&session_id, archived_worktree_id)
 468                .await
 469        })
 470    }
 471
 472    pub fn get_archived_worktrees_for_thread(
 473        &self,
 474        session_id: String,
 475        cx: &mut Context<Self>,
 476    ) -> Task<anyhow::Result<Vec<ArchivedGitWorktree>>> {
 477        let db = self.db.clone();
 478        cx.background_spawn(async move { db.get_archived_worktrees_for_thread(&session_id).await })
 479    }
 480
 481    pub fn delete_archived_worktree(
 482        &self,
 483        id: i64,
 484        cx: &mut Context<Self>,
 485    ) -> Task<anyhow::Result<()>> {
 486        let db = self.db.clone();
 487        cx.background_spawn(async move { db.delete_archived_worktree(id).await })
 488    }
 489
 490    pub fn set_archived_worktree_restored(
 491        &self,
 492        id: i64,
 493        worktree_path: String,
 494        branch_name: Option<String>,
 495        cx: &mut Context<Self>,
 496    ) -> Task<anyhow::Result<()>> {
 497        let db = self.db.clone();
 498        cx.background_spawn(async move {
 499            db.set_archived_worktree_restored(id, &worktree_path, branch_name.as_deref())
 500                .await
 501        })
 502    }
 503
 504    fn update_archived(
 505        &mut self,
 506        session_id: &acp::SessionId,
 507        archived: bool,
 508        cx: &mut Context<Self>,
 509    ) {
 510        if !cx.has_flag::<AgentV2FeatureFlag>() {
 511            return;
 512        }
 513
 514        if let Some(thread) = self.threads.get(session_id) {
 515            self.save_internal(ThreadMetadata {
 516                archived,
 517                ..thread.clone()
 518            });
 519            cx.notify();
 520        }
 521    }
 522
 523    pub fn set_pending_worktree_restore(
 524        &mut self,
 525        session_id: &acp::SessionId,
 526        main_repo_path: Option<PathBuf>,
 527        cx: &mut Context<Self>,
 528    ) {
 529        if let Some(thread) = self.threads.get_mut(session_id) {
 530            thread.pending_worktree_restore = main_repo_path;
 531            cx.notify();
 532        }
 533    }
 534
 535    pub fn complete_worktree_restore(
 536        &mut self,
 537        session_id: &acp::SessionId,
 538        new_folder_paths: PathList,
 539        cx: &mut Context<Self>,
 540    ) {
 541        if let Some(thread) = self.threads.get(session_id).cloned() {
 542            let mut updated = thread;
 543            updated.pending_worktree_restore = None;
 544            updated.folder_paths = new_folder_paths;
 545            self.save_internal(updated);
 546            cx.notify();
 547        }
 548    }
 549
 550    pub fn delete(&mut self, session_id: acp::SessionId, cx: &mut Context<Self>) {
 551        if !cx.has_flag::<AgentV2FeatureFlag>() {
 552            return;
 553        }
 554
 555        if let Some(thread) = self.threads.get(&session_id) {
 556            if let Some(session_ids) = self.threads_by_paths.get_mut(&thread.folder_paths) {
 557                session_ids.remove(&session_id);
 558            }
 559            if !thread.main_worktree_paths.is_empty() {
 560                if let Some(session_ids) = self
 561                    .threads_by_main_paths
 562                    .get_mut(&thread.main_worktree_paths)
 563                {
 564                    session_ids.remove(&session_id);
 565                }
 566            }
 567        }
 568        self.threads.remove(&session_id);
 569        self.pending_thread_ops_tx
 570            .try_send(DbOperation::Delete(session_id))
 571            .log_err();
 572        cx.notify();
 573    }
 574
 575    fn new(db: ThreadMetadataDb, cx: &mut Context<Self>) -> Self {
 576        let weak_store = cx.weak_entity();
 577
 578        cx.observe_new::<acp_thread::AcpThread>(move |thread, _window, cx| {
 579            // Don't track subagent threads in the sidebar.
 580            if thread.parent_session_id().is_some() {
 581                return;
 582            }
 583
 584            let thread_entity = cx.entity();
 585
 586            cx.on_release({
 587                let weak_store = weak_store.clone();
 588                move |thread, cx| {
 589                    weak_store
 590                        .update(cx, |store, _cx| {
 591                            let session_id = thread.session_id().clone();
 592                            store.session_subscriptions.remove(&session_id);
 593                        })
 594                        .ok();
 595                }
 596            })
 597            .detach();
 598
 599            weak_store
 600                .update(cx, |this, cx| {
 601                    let subscription = cx.subscribe(&thread_entity, Self::handle_thread_event);
 602                    this.session_subscriptions
 603                        .insert(thread.session_id().clone(), subscription);
 604                })
 605                .ok();
 606        })
 607        .detach();
 608
 609        let (tx, rx) = smol::channel::unbounded();
 610        let _db_operations_task = cx.background_spawn({
 611            let db = db.clone();
 612            async move {
 613                while let Ok(first_update) = rx.recv().await {
 614                    let mut updates = vec![first_update];
 615                    while let Ok(update) = rx.try_recv() {
 616                        updates.push(update);
 617                    }
 618                    let updates = Self::dedup_db_operations(updates);
 619                    for operation in updates {
 620                        match operation {
 621                            DbOperation::Upsert(metadata) => {
 622                                db.save(metadata).await.log_err();
 623                            }
 624                            DbOperation::Delete(session_id) => {
 625                                db.delete(session_id).await.log_err();
 626                            }
 627                        }
 628                    }
 629                }
 630            }
 631        });
 632
 633        let mut this = Self {
 634            db,
 635            threads: HashMap::default(),
 636            threads_by_paths: HashMap::default(),
 637            threads_by_main_paths: HashMap::default(),
 638            reload_task: None,
 639            session_subscriptions: HashMap::default(),
 640            pending_thread_ops_tx: tx,
 641            _db_operations_task,
 642        };
 643        let _ = this.reload(cx);
 644        this
 645    }
 646
 647    fn dedup_db_operations(operations: Vec<DbOperation>) -> Vec<DbOperation> {
 648        let mut ops = HashMap::default();
 649        for operation in operations.into_iter().rev() {
 650            if ops.contains_key(operation.id()) {
 651                continue;
 652            }
 653            ops.insert(operation.id().clone(), operation);
 654        }
 655        ops.into_values().collect()
 656    }
 657
 658    fn handle_thread_event(
 659        &mut self,
 660        thread: Entity<acp_thread::AcpThread>,
 661        event: &AcpThreadEvent,
 662        cx: &mut Context<Self>,
 663    ) {
 664        // Don't track subagent threads in the sidebar.
 665        if thread.read(cx).parent_session_id().is_some() {
 666            return;
 667        }
 668
 669        match event {
 670            AcpThreadEvent::NewEntry
 671            | AcpThreadEvent::TitleUpdated
 672            | AcpThreadEvent::EntryUpdated(_)
 673            | AcpThreadEvent::EntriesRemoved(_)
 674            | AcpThreadEvent::ToolAuthorizationRequested(_)
 675            | AcpThreadEvent::ToolAuthorizationReceived(_)
 676            | AcpThreadEvent::Retry(_)
 677            | AcpThreadEvent::Stopped(_)
 678            | AcpThreadEvent::Error
 679            | AcpThreadEvent::LoadError(_)
 680            | AcpThreadEvent::Refusal
 681            | AcpThreadEvent::WorkingDirectoriesUpdated => {
 682                let thread_ref = thread.read(cx);
 683                if thread_ref.entries().is_empty() {
 684                    return;
 685                }
 686
 687                let existing_thread = self.threads.get(thread_ref.session_id());
 688                let session_id = thread_ref.session_id().clone();
 689                let title = thread_ref
 690                    .title()
 691                    .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
 692
 693                let updated_at = Utc::now();
 694
 695                let created_at = existing_thread
 696                    .and_then(|t| t.created_at)
 697                    .unwrap_or_else(|| updated_at);
 698
 699                let agent_id = thread_ref.connection().agent_id();
 700
 701                let folder_paths = {
 702                    let project = thread_ref.project().read(cx);
 703                    let paths: Vec<Arc<Path>> = project
 704                        .visible_worktrees(cx)
 705                        .map(|worktree| worktree.read(cx).abs_path())
 706                        .collect();
 707                    PathList::new(&paths)
 708                };
 709
 710                let main_worktree_paths = {
 711                    let project = thread_ref.project().read(cx);
 712                    let mut main_paths: Vec<Arc<Path>> = Vec::new();
 713                    for repo in project.repositories(cx).values() {
 714                        let snapshot = repo.read(cx).snapshot();
 715                        if snapshot.is_linked_worktree() {
 716                            main_paths.push(snapshot.original_repo_abs_path.clone());
 717                        }
 718                    }
 719                    main_paths.sort();
 720                    main_paths.dedup();
 721                    PathList::new(&main_paths)
 722                };
 723
 724                // Threads without a folder path (e.g. started in an empty
 725                // window) are archived by default so they don't get lost,
 726                // because they won't show up in the sidebar. Users can reload
 727                // them from the archive.
 728                let archived = existing_thread
 729                    .map(|t| t.archived)
 730                    .unwrap_or(folder_paths.is_empty());
 731
 732                let metadata = ThreadMetadata {
 733                    session_id,
 734                    agent_id,
 735                    title,
 736                    created_at: Some(created_at),
 737                    updated_at,
 738                    folder_paths,
 739                    main_worktree_paths,
 740                    archived,
 741                    pending_worktree_restore: None,
 742                };
 743
 744                self.save(metadata, cx);
 745            }
 746            AcpThreadEvent::TokenUsageUpdated
 747            | AcpThreadEvent::SubagentSpawned(_)
 748            | AcpThreadEvent::PromptCapabilitiesUpdated
 749            | AcpThreadEvent::AvailableCommandsUpdated(_)
 750            | AcpThreadEvent::ModeUpdated(_)
 751            | AcpThreadEvent::ConfigOptionsUpdated(_) => {}
 752        }
 753    }
 754}
 755
 756impl Global for ThreadMetadataStore {}
 757
 758struct ThreadMetadataDb(ThreadSafeConnection);
 759
 760impl Domain for ThreadMetadataDb {
 761    const NAME: &str = stringify!(ThreadMetadataDb);
 762
 763    const MIGRATIONS: &[&str] = &[
 764        sql!(
 765            CREATE TABLE IF NOT EXISTS sidebar_threads(
 766                session_id TEXT PRIMARY KEY,
 767                agent_id TEXT,
 768                title TEXT NOT NULL,
 769                updated_at TEXT NOT NULL,
 770                created_at TEXT,
 771                folder_paths TEXT,
 772                folder_paths_order TEXT
 773            ) STRICT;
 774        ),
 775        sql!(ALTER TABLE sidebar_threads ADD COLUMN archived INTEGER DEFAULT 0),
 776        sql!(ALTER TABLE sidebar_threads ADD COLUMN main_worktree_paths TEXT),
 777        sql!(ALTER TABLE sidebar_threads ADD COLUMN main_worktree_paths_order TEXT),
 778        sql!(
 779            CREATE TABLE IF NOT EXISTS archived_git_worktrees(
 780                id INTEGER PRIMARY KEY,
 781                worktree_path TEXT NOT NULL,
 782                main_repo_path TEXT NOT NULL,
 783                branch_name TEXT,
 784                commit_hash TEXT NOT NULL,
 785                restored INTEGER NOT NULL DEFAULT 0
 786            ) STRICT;
 787            CREATE TABLE IF NOT EXISTS thread_archived_worktrees(
 788                session_id TEXT NOT NULL,
 789                archived_worktree_id INTEGER NOT NULL REFERENCES archived_git_worktrees(id),
 790                PRIMARY KEY (session_id, archived_worktree_id)
 791            ) STRICT;
 792        ),
 793    ];
 794}
 795
 796db::static_connection!(ThreadMetadataDb, []);
 797
 798impl ThreadMetadataDb {
 799    pub fn list_ids(&self) -> anyhow::Result<Vec<Arc<str>>> {
 800        self.select::<Arc<str>>(
 801            "SELECT session_id FROM sidebar_threads \
 802             ORDER BY updated_at DESC",
 803        )?()
 804    }
 805
 806    /// List all sidebar thread metadata, ordered by updated_at descending.
 807    pub fn list(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
 808        self.select::<ThreadMetadata>(
 809            "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived, main_worktree_paths, main_worktree_paths_order \
 810             FROM sidebar_threads \
 811             ORDER BY updated_at DESC"
 812        )?()
 813    }
 814
 815    /// Upsert metadata for a thread.
 816    pub async fn save(&self, row: ThreadMetadata) -> anyhow::Result<()> {
 817        let id = row.session_id.0.clone();
 818        let agent_id = if row.agent_id.as_ref() == ZED_AGENT_ID.as_ref() {
 819            None
 820        } else {
 821            Some(row.agent_id.to_string())
 822        };
 823        let title = row.title.to_string();
 824        let updated_at = row.updated_at.to_rfc3339();
 825        let created_at = row.created_at.map(|dt| dt.to_rfc3339());
 826        let serialized = row.folder_paths.serialize();
 827        let (folder_paths, folder_paths_order) = if row.folder_paths.is_empty() {
 828            (None, None)
 829        } else {
 830            (Some(serialized.paths), Some(serialized.order))
 831        };
 832        let main_serialized = row.main_worktree_paths.serialize();
 833        let (main_worktree_paths, main_worktree_paths_order) = if row.main_worktree_paths.is_empty()
 834        {
 835            (None, None)
 836        } else {
 837            (Some(main_serialized.paths), Some(main_serialized.order))
 838        };
 839        let archived = row.archived;
 840
 841        self.write(move |conn| {
 842            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) \
 843                       VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \
 844                       ON CONFLICT(session_id) DO UPDATE SET \
 845                           agent_id = excluded.agent_id, \
 846                           title = excluded.title, \
 847                           updated_at = excluded.updated_at, \
 848                           created_at = excluded.created_at, \
 849                           folder_paths = excluded.folder_paths, \
 850                           folder_paths_order = excluded.folder_paths_order, \
 851                           archived = excluded.archived, \
 852                           main_worktree_paths = excluded.main_worktree_paths, \
 853                           main_worktree_paths_order = excluded.main_worktree_paths_order";
 854            let mut stmt = Statement::prepare(conn, sql)?;
 855            let mut i = stmt.bind(&id, 1)?;
 856            i = stmt.bind(&agent_id, i)?;
 857            i = stmt.bind(&title, i)?;
 858            i = stmt.bind(&updated_at, i)?;
 859            i = stmt.bind(&created_at, i)?;
 860            i = stmt.bind(&folder_paths, i)?;
 861            i = stmt.bind(&folder_paths_order, i)?;
 862            i = stmt.bind(&archived, i)?;
 863            i = stmt.bind(&main_worktree_paths, i)?;
 864            stmt.bind(&main_worktree_paths_order, i)?;
 865            stmt.exec()
 866        })
 867        .await
 868    }
 869
 870    pub async fn create_archived_worktree(
 871        &self,
 872        worktree_path: &str,
 873        main_repo_path: &str,
 874        branch_name: Option<&str>,
 875        commit_hash: &str,
 876    ) -> anyhow::Result<i64> {
 877        let worktree_path = worktree_path.to_string();
 878        let main_repo_path = main_repo_path.to_string();
 879        let branch_name = branch_name.map(|s| s.to_string());
 880        let commit_hash = commit_hash.to_string();
 881        self.write(move |conn| {
 882            let id: i64 =
 883                conn.select_row_bound::<_, i64>(sql!(
 884                    INSERT INTO archived_git_worktrees(
 885                        worktree_path, main_repo_path, branch_name, commit_hash
 886                    ) VALUES (?1, ?2, ?3, ?4)
 887                    RETURNING id
 888                ))?((worktree_path, main_repo_path, branch_name, commit_hash))?
 889                .context("Could not retrieve inserted archived worktree id")?;
 890            Ok(id)
 891        })
 892        .await
 893    }
 894
 895    pub async fn link_thread_to_archived_worktree(
 896        &self,
 897        session_id: &str,
 898        archived_worktree_id: i64,
 899    ) -> anyhow::Result<()> {
 900        let session_id = session_id.to_string();
 901        self.write(move |conn| {
 902            let mut stmt = Statement::prepare(
 903                conn,
 904                "INSERT INTO thread_archived_worktrees(\
 905                     session_id, archived_worktree_id\
 906                 ) VALUES (?, ?)",
 907            )?;
 908            let i = stmt.bind(&session_id, 1)?;
 909            stmt.bind(&archived_worktree_id, i)?;
 910            stmt.exec()
 911        })
 912        .await
 913    }
 914
 915    pub async fn get_archived_worktrees_for_thread(
 916        &self,
 917        session_id: &str,
 918    ) -> anyhow::Result<Vec<ArchivedGitWorktree>> {
 919        let session_id = session_id.to_string();
 920        self.select_bound(
 921            "SELECT aw.id, aw.worktree_path, aw.main_repo_path, aw.branch_name, aw.commit_hash, aw.restored \
 922             FROM archived_git_worktrees aw \
 923             JOIN thread_archived_worktrees taw ON taw.archived_worktree_id = aw.id \
 924             WHERE taw.session_id = ?",
 925        )?(session_id)
 926    }
 927
 928    pub async fn delete_archived_worktree(&self, id: i64) -> anyhow::Result<()> {
 929        self.write(move |conn| {
 930            let mut stmt = Statement::prepare(
 931                conn,
 932                "DELETE FROM thread_archived_worktrees WHERE archived_worktree_id = ?",
 933            )?;
 934            stmt.bind(&id, 1)?;
 935            stmt.exec()?;
 936
 937            let mut stmt =
 938                Statement::prepare(conn, "DELETE FROM archived_git_worktrees WHERE id = ?")?;
 939            stmt.bind(&id, 1)?;
 940            stmt.exec()
 941        })
 942        .await
 943    }
 944
 945    pub async fn set_archived_worktree_restored(
 946        &self,
 947        id: i64,
 948        worktree_path: &str,
 949        branch_name: Option<&str>,
 950    ) -> anyhow::Result<()> {
 951        let worktree_path = worktree_path.to_string();
 952        let branch_name = branch_name.map(|s| s.to_string());
 953        self.write(move |conn| {
 954            let mut stmt = Statement::prepare(
 955                conn,
 956                "UPDATE archived_git_worktrees \
 957                 SET restored = 1, worktree_path = ?, branch_name = ? \
 958                 WHERE id = ?",
 959            )?;
 960            let mut i = stmt.bind(&worktree_path, 1)?;
 961            i = stmt.bind(&branch_name, i)?;
 962            stmt.bind(&id, i)?;
 963            stmt.exec()
 964        })
 965        .await
 966    }
 967
 968    /// Delete metadata for a single thread.
 969    pub async fn delete(&self, session_id: acp::SessionId) -> anyhow::Result<()> {
 970        let id = session_id.0.clone();
 971        self.write(move |conn| {
 972            let mut stmt =
 973                Statement::prepare(conn, "DELETE FROM sidebar_threads WHERE session_id = ?")?;
 974            stmt.bind(&id, 1)?;
 975            stmt.exec()
 976        })
 977        .await
 978    }
 979}
 980
 981impl Column for ThreadMetadata {
 982    fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
 983        let (id, next): (Arc<str>, i32) = Column::column(statement, start_index)?;
 984        let (agent_id, next): (Option<String>, i32) = Column::column(statement, next)?;
 985        let (title, next): (String, i32) = Column::column(statement, next)?;
 986        let (updated_at_str, next): (String, i32) = Column::column(statement, next)?;
 987        let (created_at_str, next): (Option<String>, i32) = Column::column(statement, next)?;
 988        let (folder_paths_str, next): (Option<String>, i32) = Column::column(statement, next)?;
 989        let (folder_paths_order_str, next): (Option<String>, i32) =
 990            Column::column(statement, next)?;
 991        let (archived, next): (bool, i32) = Column::column(statement, next)?;
 992        let (main_worktree_paths_str, next): (Option<String>, i32) =
 993            Column::column(statement, next)?;
 994        let (main_worktree_paths_order_str, next): (Option<String>, i32) =
 995            Column::column(statement, next)?;
 996
 997        let agent_id = agent_id
 998            .map(|id| AgentId::new(id))
 999            .unwrap_or(ZED_AGENT_ID.clone());
1000
1001        let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc);
1002        let created_at = created_at_str
1003            .as_deref()
1004            .map(DateTime::parse_from_rfc3339)
1005            .transpose()?
1006            .map(|dt| dt.with_timezone(&Utc));
1007
1008        let folder_paths = folder_paths_str
1009            .map(|paths| {
1010                PathList::deserialize(&util::path_list::SerializedPathList {
1011                    paths,
1012                    order: folder_paths_order_str.unwrap_or_default(),
1013                })
1014            })
1015            .unwrap_or_default();
1016
1017        let main_worktree_paths = main_worktree_paths_str
1018            .map(|paths| {
1019                PathList::deserialize(&util::path_list::SerializedPathList {
1020                    paths,
1021                    order: main_worktree_paths_order_str.unwrap_or_default(),
1022                })
1023            })
1024            .unwrap_or_default();
1025
1026        Ok((
1027            ThreadMetadata {
1028                session_id: acp::SessionId::new(id),
1029                agent_id,
1030                title: title.into(),
1031                updated_at,
1032                created_at,
1033                folder_paths,
1034                main_worktree_paths,
1035                archived,
1036                pending_worktree_restore: None,
1037            },
1038            next,
1039        ))
1040    }
1041}
1042
1043impl Column for ArchivedGitWorktree {
1044    fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
1045        let (id, next): (i64, i32) = Column::column(statement, start_index)?;
1046        let (worktree_path_str, next): (String, i32) = Column::column(statement, next)?;
1047        let (main_repo_path_str, next): (String, i32) = Column::column(statement, next)?;
1048        let (branch_name, next): (Option<String>, i32) = Column::column(statement, next)?;
1049        let (commit_hash, next): (String, i32) = Column::column(statement, next)?;
1050        let (restored_int, next): (i64, i32) = Column::column(statement, next)?;
1051        Ok((
1052            ArchivedGitWorktree {
1053                id,
1054                worktree_path: PathBuf::from(worktree_path_str),
1055                main_repo_path: PathBuf::from(main_repo_path_str),
1056                branch_name,
1057                commit_hash,
1058                restored: restored_int != 0,
1059            },
1060            next,
1061        ))
1062    }
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067    use super::*;
1068    use acp_thread::{AgentConnection, StubAgentConnection};
1069    use action_log::ActionLog;
1070    use agent::DbThread;
1071    use agent_client_protocol as acp;
1072    use feature_flags::FeatureFlagAppExt;
1073    use gpui::TestAppContext;
1074    use project::FakeFs;
1075    use project::Project;
1076    use std::path::Path;
1077    use std::rc::Rc;
1078
1079    fn make_db_thread(title: &str, updated_at: DateTime<Utc>) -> DbThread {
1080        DbThread {
1081            title: title.to_string().into(),
1082            messages: Vec::new(),
1083            updated_at,
1084            detailed_summary: None,
1085            initial_project_snapshot: None,
1086            cumulative_token_usage: Default::default(),
1087            request_token_usage: Default::default(),
1088            model: None,
1089            profile: None,
1090            imported: false,
1091            subagent_context: None,
1092            speed: None,
1093            thinking_enabled: false,
1094            thinking_effort: None,
1095            draft_prompt: None,
1096            ui_scroll_position: None,
1097        }
1098    }
1099
1100    fn make_metadata(
1101        session_id: &str,
1102        title: &str,
1103        updated_at: DateTime<Utc>,
1104        folder_paths: PathList,
1105    ) -> ThreadMetadata {
1106        ThreadMetadata {
1107            archived: false,
1108            session_id: acp::SessionId::new(session_id),
1109            agent_id: agent::ZED_AGENT_ID.clone(),
1110            title: title.to_string().into(),
1111            updated_at,
1112            created_at: Some(updated_at),
1113            folder_paths,
1114            main_worktree_paths: PathList::default(),
1115            pending_worktree_restore: None,
1116        }
1117    }
1118
1119    fn init_test(cx: &mut TestAppContext) {
1120        cx.update(|cx| {
1121            let settings_store = settings::SettingsStore::test(cx);
1122            cx.set_global(settings_store);
1123            cx.update_flags(true, vec!["agent-v2".to_string()]);
1124            ThreadMetadataStore::init_global(cx);
1125            ThreadStore::init_global(cx);
1126        });
1127        cx.run_until_parked();
1128    }
1129
1130    #[gpui::test]
1131    async fn test_store_initializes_cache_from_database(cx: &mut TestAppContext) {
1132        let first_paths = PathList::new(&[Path::new("/project-a")]);
1133        let second_paths = PathList::new(&[Path::new("/project-b")]);
1134        let now = Utc::now();
1135        let older = now - chrono::Duration::seconds(1);
1136
1137        let thread = std::thread::current();
1138        let test_name = thread.name().unwrap_or("unknown_test");
1139        let db_name = format!("THREAD_METADATA_DB_{}", test_name);
1140        let db = ThreadMetadataDb(smol::block_on(db::open_test_db::<ThreadMetadataDb>(
1141            &db_name,
1142        )));
1143
1144        db.save(make_metadata(
1145            "session-1",
1146            "First Thread",
1147            now,
1148            first_paths.clone(),
1149        ))
1150        .await
1151        .unwrap();
1152        db.save(make_metadata(
1153            "session-2",
1154            "Second Thread",
1155            older,
1156            second_paths.clone(),
1157        ))
1158        .await
1159        .unwrap();
1160
1161        cx.update(|cx| {
1162            let settings_store = settings::SettingsStore::test(cx);
1163            cx.set_global(settings_store);
1164            cx.update_flags(true, vec!["agent-v2".to_string()]);
1165            ThreadMetadataStore::init_global(cx);
1166        });
1167
1168        cx.run_until_parked();
1169
1170        cx.update(|cx| {
1171            let store = ThreadMetadataStore::global(cx);
1172            let store = store.read(cx);
1173
1174            let entry_ids = store
1175                .entry_ids()
1176                .map(|session_id| session_id.0.to_string())
1177                .collect::<Vec<_>>();
1178            assert_eq!(entry_ids.len(), 2);
1179            assert!(entry_ids.contains(&"session-1".to_string()));
1180            assert!(entry_ids.contains(&"session-2".to_string()));
1181
1182            let first_path_entries = store
1183                .entries_for_path(&first_paths)
1184                .map(|entry| entry.session_id.0.to_string())
1185                .collect::<Vec<_>>();
1186            assert_eq!(first_path_entries, vec!["session-1"]);
1187
1188            let second_path_entries = store
1189                .entries_for_path(&second_paths)
1190                .map(|entry| entry.session_id.0.to_string())
1191                .collect::<Vec<_>>();
1192            assert_eq!(second_path_entries, vec!["session-2"]);
1193        });
1194    }
1195
1196    #[gpui::test]
1197    async fn test_store_cache_updates_after_save_and_delete(cx: &mut TestAppContext) {
1198        init_test(cx);
1199
1200        let first_paths = PathList::new(&[Path::new("/project-a")]);
1201        let second_paths = PathList::new(&[Path::new("/project-b")]);
1202        let initial_time = Utc::now();
1203        let updated_time = initial_time + chrono::Duration::seconds(1);
1204
1205        let initial_metadata = make_metadata(
1206            "session-1",
1207            "First Thread",
1208            initial_time,
1209            first_paths.clone(),
1210        );
1211
1212        let second_metadata = make_metadata(
1213            "session-2",
1214            "Second Thread",
1215            initial_time,
1216            second_paths.clone(),
1217        );
1218
1219        cx.update(|cx| {
1220            let store = ThreadMetadataStore::global(cx);
1221            store.update(cx, |store, cx| {
1222                store.save(initial_metadata, cx);
1223                store.save(second_metadata, cx);
1224            });
1225        });
1226
1227        cx.run_until_parked();
1228
1229        cx.update(|cx| {
1230            let store = ThreadMetadataStore::global(cx);
1231            let store = store.read(cx);
1232
1233            let first_path_entries = store
1234                .entries_for_path(&first_paths)
1235                .map(|entry| entry.session_id.0.to_string())
1236                .collect::<Vec<_>>();
1237            assert_eq!(first_path_entries, vec!["session-1"]);
1238
1239            let second_path_entries = store
1240                .entries_for_path(&second_paths)
1241                .map(|entry| entry.session_id.0.to_string())
1242                .collect::<Vec<_>>();
1243            assert_eq!(second_path_entries, vec!["session-2"]);
1244        });
1245
1246        let moved_metadata = make_metadata(
1247            "session-1",
1248            "First Thread",
1249            updated_time,
1250            second_paths.clone(),
1251        );
1252
1253        cx.update(|cx| {
1254            let store = ThreadMetadataStore::global(cx);
1255            store.update(cx, |store, cx| {
1256                store.save(moved_metadata, cx);
1257            });
1258        });
1259
1260        cx.run_until_parked();
1261
1262        cx.update(|cx| {
1263            let store = ThreadMetadataStore::global(cx);
1264            let store = store.read(cx);
1265
1266            let entry_ids = store
1267                .entry_ids()
1268                .map(|session_id| session_id.0.to_string())
1269                .collect::<Vec<_>>();
1270            assert_eq!(entry_ids.len(), 2);
1271            assert!(entry_ids.contains(&"session-1".to_string()));
1272            assert!(entry_ids.contains(&"session-2".to_string()));
1273
1274            let first_path_entries = store
1275                .entries_for_path(&first_paths)
1276                .map(|entry| entry.session_id.0.to_string())
1277                .collect::<Vec<_>>();
1278            assert!(first_path_entries.is_empty());
1279
1280            let second_path_entries = store
1281                .entries_for_path(&second_paths)
1282                .map(|entry| entry.session_id.0.to_string())
1283                .collect::<Vec<_>>();
1284            assert_eq!(second_path_entries.len(), 2);
1285            assert!(second_path_entries.contains(&"session-1".to_string()));
1286            assert!(second_path_entries.contains(&"session-2".to_string()));
1287        });
1288
1289        cx.update(|cx| {
1290            let store = ThreadMetadataStore::global(cx);
1291            store.update(cx, |store, cx| {
1292                store.delete(acp::SessionId::new("session-2"), cx);
1293            });
1294        });
1295
1296        cx.run_until_parked();
1297
1298        cx.update(|cx| {
1299            let store = ThreadMetadataStore::global(cx);
1300            let store = store.read(cx);
1301
1302            let entry_ids = store
1303                .entry_ids()
1304                .map(|session_id| session_id.0.to_string())
1305                .collect::<Vec<_>>();
1306            assert_eq!(entry_ids, vec!["session-1"]);
1307
1308            let second_path_entries = store
1309                .entries_for_path(&second_paths)
1310                .map(|entry| entry.session_id.0.to_string())
1311                .collect::<Vec<_>>();
1312            assert_eq!(second_path_entries, vec!["session-1"]);
1313        });
1314    }
1315
1316    #[gpui::test]
1317    async fn test_migrate_thread_metadata_migrates_only_missing_threads(cx: &mut TestAppContext) {
1318        init_test(cx);
1319
1320        let project_a_paths = PathList::new(&[Path::new("/project-a")]);
1321        let project_b_paths = PathList::new(&[Path::new("/project-b")]);
1322        let now = Utc::now();
1323
1324        let existing_metadata = ThreadMetadata {
1325            session_id: acp::SessionId::new("a-session-0"),
1326            agent_id: agent::ZED_AGENT_ID.clone(),
1327            title: "Existing Metadata".into(),
1328            updated_at: now - chrono::Duration::seconds(10),
1329            created_at: Some(now - chrono::Duration::seconds(10)),
1330            folder_paths: project_a_paths.clone(),
1331            main_worktree_paths: PathList::default(),
1332            archived: false,
1333            pending_worktree_restore: None,
1334        };
1335
1336        cx.update(|cx| {
1337            let store = ThreadMetadataStore::global(cx);
1338            store.update(cx, |store, cx| {
1339                store.save(existing_metadata, cx);
1340            });
1341        });
1342        cx.run_until_parked();
1343
1344        let threads_to_save = vec![
1345            (
1346                "a-session-0",
1347                "Thread A0 From Native Store",
1348                project_a_paths.clone(),
1349                now,
1350            ),
1351            (
1352                "a-session-1",
1353                "Thread A1",
1354                project_a_paths.clone(),
1355                now + chrono::Duration::seconds(1),
1356            ),
1357            (
1358                "b-session-0",
1359                "Thread B0",
1360                project_b_paths.clone(),
1361                now + chrono::Duration::seconds(2),
1362            ),
1363            (
1364                "projectless",
1365                "Projectless",
1366                PathList::default(),
1367                now + chrono::Duration::seconds(3),
1368            ),
1369        ];
1370
1371        for (session_id, title, paths, updated_at) in &threads_to_save {
1372            let save_task = cx.update(|cx| {
1373                let thread_store = ThreadStore::global(cx);
1374                let session_id = session_id.to_string();
1375                let title = title.to_string();
1376                let paths = paths.clone();
1377                thread_store.update(cx, |store, cx| {
1378                    store.save_thread(
1379                        acp::SessionId::new(session_id),
1380                        make_db_thread(&title, *updated_at),
1381                        paths,
1382                        cx,
1383                    )
1384                })
1385            });
1386            save_task.await.unwrap();
1387            cx.run_until_parked();
1388        }
1389
1390        cx.update(|cx| migrate_thread_metadata(cx));
1391        cx.run_until_parked();
1392
1393        let list = cx.update(|cx| {
1394            let store = ThreadMetadataStore::global(cx);
1395            store.read(cx).entries().cloned().collect::<Vec<_>>()
1396        });
1397
1398        assert_eq!(list.len(), 4);
1399        assert!(
1400            list.iter()
1401                .all(|metadata| metadata.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref())
1402        );
1403
1404        let existing_metadata = list
1405            .iter()
1406            .find(|metadata| metadata.session_id.0.as_ref() == "a-session-0")
1407            .unwrap();
1408        assert_eq!(existing_metadata.title.as_ref(), "Existing Metadata");
1409        assert!(!existing_metadata.archived);
1410
1411        let migrated_session_ids = list
1412            .iter()
1413            .map(|metadata| metadata.session_id.0.as_ref())
1414            .collect::<Vec<_>>();
1415        assert!(migrated_session_ids.contains(&"a-session-1"));
1416        assert!(migrated_session_ids.contains(&"b-session-0"));
1417        assert!(migrated_session_ids.contains(&"projectless"));
1418
1419        let migrated_entries = list
1420            .iter()
1421            .filter(|metadata| metadata.session_id.0.as_ref() != "a-session-0")
1422            .collect::<Vec<_>>();
1423        assert!(migrated_entries.iter().all(|metadata| metadata.archived));
1424    }
1425
1426    #[gpui::test]
1427    async fn test_migrate_thread_metadata_noops_when_all_threads_already_exist(
1428        cx: &mut TestAppContext,
1429    ) {
1430        init_test(cx);
1431
1432        let project_paths = PathList::new(&[Path::new("/project-a")]);
1433        let existing_updated_at = Utc::now();
1434
1435        let existing_metadata = ThreadMetadata {
1436            session_id: acp::SessionId::new("existing-session"),
1437            agent_id: agent::ZED_AGENT_ID.clone(),
1438            title: "Existing Metadata".into(),
1439            updated_at: existing_updated_at,
1440            created_at: Some(existing_updated_at),
1441            folder_paths: project_paths.clone(),
1442            main_worktree_paths: PathList::default(),
1443            archived: false,
1444            pending_worktree_restore: None,
1445        };
1446
1447        cx.update(|cx| {
1448            let store = ThreadMetadataStore::global(cx);
1449            store.update(cx, |store, cx| {
1450                store.save(existing_metadata, cx);
1451            });
1452        });
1453        cx.run_until_parked();
1454
1455        let save_task = cx.update(|cx| {
1456            let thread_store = ThreadStore::global(cx);
1457            thread_store.update(cx, |store, cx| {
1458                store.save_thread(
1459                    acp::SessionId::new("existing-session"),
1460                    make_db_thread(
1461                        "Updated Native Thread Title",
1462                        existing_updated_at + chrono::Duration::seconds(1),
1463                    ),
1464                    project_paths.clone(),
1465                    cx,
1466                )
1467            })
1468        });
1469        save_task.await.unwrap();
1470        cx.run_until_parked();
1471
1472        cx.update(|cx| migrate_thread_metadata(cx));
1473        cx.run_until_parked();
1474
1475        let list = cx.update(|cx| {
1476            let store = ThreadMetadataStore::global(cx);
1477            store.read(cx).entries().cloned().collect::<Vec<_>>()
1478        });
1479
1480        assert_eq!(list.len(), 1);
1481        assert_eq!(list[0].session_id.0.as_ref(), "existing-session");
1482    }
1483
1484    #[gpui::test]
1485    async fn test_migrate_thread_metadata_archives_beyond_five_most_recent_per_project(
1486        cx: &mut TestAppContext,
1487    ) {
1488        init_test(cx);
1489
1490        let project_a_paths = PathList::new(&[Path::new("/project-a")]);
1491        let project_b_paths = PathList::new(&[Path::new("/project-b")]);
1492        let now = Utc::now();
1493
1494        // Create 7 threads for project A and 3 for project B
1495        let mut threads_to_save = Vec::new();
1496        for i in 0..7 {
1497            threads_to_save.push((
1498                format!("a-session-{i}"),
1499                format!("Thread A{i}"),
1500                project_a_paths.clone(),
1501                now + chrono::Duration::seconds(i as i64),
1502            ));
1503        }
1504        for i in 0..3 {
1505            threads_to_save.push((
1506                format!("b-session-{i}"),
1507                format!("Thread B{i}"),
1508                project_b_paths.clone(),
1509                now + chrono::Duration::seconds(i as i64),
1510            ));
1511        }
1512
1513        for (session_id, title, paths, updated_at) in &threads_to_save {
1514            let save_task = cx.update(|cx| {
1515                let thread_store = ThreadStore::global(cx);
1516                let session_id = session_id.to_string();
1517                let title = title.to_string();
1518                let paths = paths.clone();
1519                thread_store.update(cx, |store, cx| {
1520                    store.save_thread(
1521                        acp::SessionId::new(session_id),
1522                        make_db_thread(&title, *updated_at),
1523                        paths,
1524                        cx,
1525                    )
1526                })
1527            });
1528            save_task.await.unwrap();
1529            cx.run_until_parked();
1530        }
1531
1532        cx.update(|cx| migrate_thread_metadata(cx));
1533        cx.run_until_parked();
1534
1535        let list = cx.update(|cx| {
1536            let store = ThreadMetadataStore::global(cx);
1537            store.read(cx).entries().cloned().collect::<Vec<_>>()
1538        });
1539
1540        assert_eq!(list.len(), 10);
1541
1542        // Project A: 5 most recent should be unarchived, 2 oldest should be archived
1543        let mut project_a_entries: Vec<_> = list
1544            .iter()
1545            .filter(|m| m.folder_paths == project_a_paths)
1546            .collect();
1547        assert_eq!(project_a_entries.len(), 7);
1548        project_a_entries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
1549
1550        for entry in &project_a_entries[..5] {
1551            assert!(
1552                !entry.archived,
1553                "Expected {} to be unarchived (top 5 most recent)",
1554                entry.session_id.0
1555            );
1556        }
1557        for entry in &project_a_entries[5..] {
1558            assert!(
1559                entry.archived,
1560                "Expected {} to be archived (older than top 5)",
1561                entry.session_id.0
1562            );
1563        }
1564
1565        // Project B: all 3 should be unarchived (under the limit)
1566        let project_b_entries: Vec<_> = list
1567            .iter()
1568            .filter(|m| m.folder_paths == project_b_paths)
1569            .collect();
1570        assert_eq!(project_b_entries.len(), 3);
1571        assert!(project_b_entries.iter().all(|m| !m.archived));
1572    }
1573
1574    #[gpui::test]
1575    async fn test_empty_thread_events_do_not_create_metadata(cx: &mut TestAppContext) {
1576        init_test(cx);
1577
1578        let fs = FakeFs::new(cx.executor());
1579        let project = Project::test(fs, None::<&Path>, cx).await;
1580        let connection = Rc::new(StubAgentConnection::new());
1581
1582        let thread = cx
1583            .update(|cx| {
1584                connection
1585                    .clone()
1586                    .new_session(project.clone(), PathList::default(), cx)
1587            })
1588            .await
1589            .unwrap();
1590        let session_id = cx.read(|cx| thread.read(cx).session_id().clone());
1591
1592        cx.update(|cx| {
1593            thread.update(cx, |thread, cx| {
1594                thread.set_title("Draft Thread".into(), cx).detach();
1595            });
1596        });
1597        cx.run_until_parked();
1598
1599        let metadata_ids = cx.update(|cx| {
1600            ThreadMetadataStore::global(cx)
1601                .read(cx)
1602                .entry_ids()
1603                .collect::<Vec<_>>()
1604        });
1605        assert!(
1606            metadata_ids.is_empty(),
1607            "expected empty draft thread title updates to be ignored"
1608        );
1609
1610        cx.update(|cx| {
1611            thread.update(cx, |thread, cx| {
1612                thread.push_user_content_block(None, "Hello".into(), cx);
1613            });
1614        });
1615        cx.run_until_parked();
1616
1617        let metadata_ids = cx.update(|cx| {
1618            ThreadMetadataStore::global(cx)
1619                .read(cx)
1620                .entry_ids()
1621                .collect::<Vec<_>>()
1622        });
1623        assert_eq!(metadata_ids, vec![session_id]);
1624    }
1625
1626    #[gpui::test]
1627    async fn test_nonempty_thread_metadata_preserved_when_thread_released(cx: &mut TestAppContext) {
1628        init_test(cx);
1629
1630        let fs = FakeFs::new(cx.executor());
1631        let project = Project::test(fs, None::<&Path>, cx).await;
1632        let connection = Rc::new(StubAgentConnection::new());
1633
1634        let thread = cx
1635            .update(|cx| {
1636                connection
1637                    .clone()
1638                    .new_session(project.clone(), PathList::default(), cx)
1639            })
1640            .await
1641            .unwrap();
1642        let session_id = cx.read(|cx| thread.read(cx).session_id().clone());
1643
1644        cx.update(|cx| {
1645            thread.update(cx, |thread, cx| {
1646                thread.push_user_content_block(None, "Hello".into(), cx);
1647            });
1648        });
1649        cx.run_until_parked();
1650
1651        let metadata_ids = cx.update(|cx| {
1652            ThreadMetadataStore::global(cx)
1653                .read(cx)
1654                .entry_ids()
1655                .collect::<Vec<_>>()
1656        });
1657        assert_eq!(metadata_ids, vec![session_id.clone()]);
1658
1659        drop(thread);
1660        cx.update(|_| {});
1661        cx.run_until_parked();
1662
1663        let metadata_ids = cx.update(|cx| {
1664            ThreadMetadataStore::global(cx)
1665                .read(cx)
1666                .entry_ids()
1667                .collect::<Vec<_>>()
1668        });
1669        assert_eq!(metadata_ids, vec![session_id]);
1670    }
1671
1672    #[gpui::test]
1673    async fn test_threads_without_project_association_are_archived_by_default(
1674        cx: &mut TestAppContext,
1675    ) {
1676        init_test(cx);
1677
1678        let fs = FakeFs::new(cx.executor());
1679        let project_without_worktree = Project::test(fs.clone(), None::<&Path>, cx).await;
1680        let project_with_worktree = Project::test(fs, [Path::new("/project-a")], cx).await;
1681        let connection = Rc::new(StubAgentConnection::new());
1682
1683        let thread_without_worktree = cx
1684            .update(|cx| {
1685                connection.clone().new_session(
1686                    project_without_worktree.clone(),
1687                    PathList::default(),
1688                    cx,
1689                )
1690            })
1691            .await
1692            .unwrap();
1693        let session_without_worktree =
1694            cx.read(|cx| thread_without_worktree.read(cx).session_id().clone());
1695
1696        cx.update(|cx| {
1697            thread_without_worktree.update(cx, |thread, cx| {
1698                thread.push_user_content_block(None, "content".into(), cx);
1699                thread.set_title("No Project Thread".into(), cx).detach();
1700            });
1701        });
1702        cx.run_until_parked();
1703
1704        let thread_with_worktree = cx
1705            .update(|cx| {
1706                connection.clone().new_session(
1707                    project_with_worktree.clone(),
1708                    PathList::default(),
1709                    cx,
1710                )
1711            })
1712            .await
1713            .unwrap();
1714        let session_with_worktree =
1715            cx.read(|cx| thread_with_worktree.read(cx).session_id().clone());
1716
1717        cx.update(|cx| {
1718            thread_with_worktree.update(cx, |thread, cx| {
1719                thread.push_user_content_block(None, "content".into(), cx);
1720                thread.set_title("Project Thread".into(), cx).detach();
1721            });
1722        });
1723        cx.run_until_parked();
1724
1725        cx.update(|cx| {
1726            let store = ThreadMetadataStore::global(cx);
1727            let store = store.read(cx);
1728
1729            let without_worktree = store
1730                .entry(&session_without_worktree)
1731                .expect("missing metadata for thread without project association");
1732            assert!(without_worktree.folder_paths.is_empty());
1733            assert!(
1734                without_worktree.archived,
1735                "expected thread without project association to be archived"
1736            );
1737
1738            let with_worktree = store
1739                .entry(&session_with_worktree)
1740                .expect("missing metadata for thread with project association");
1741            assert_eq!(
1742                with_worktree.folder_paths,
1743                PathList::new(&[Path::new("/project-a")])
1744            );
1745            assert!(
1746                !with_worktree.archived,
1747                "expected thread with project association to remain unarchived"
1748            );
1749        });
1750    }
1751
1752    #[gpui::test]
1753    async fn test_subagent_threads_excluded_from_sidebar_metadata(cx: &mut TestAppContext) {
1754        init_test(cx);
1755
1756        let fs = FakeFs::new(cx.executor());
1757        let project = Project::test(fs, None::<&Path>, cx).await;
1758        let connection = Rc::new(StubAgentConnection::new());
1759
1760        // Create a regular (non-subagent) AcpThread.
1761        let regular_thread = cx
1762            .update(|cx| {
1763                connection
1764                    .clone()
1765                    .new_session(project.clone(), PathList::default(), cx)
1766            })
1767            .await
1768            .unwrap();
1769
1770        let regular_session_id = cx.read(|cx| regular_thread.read(cx).session_id().clone());
1771
1772        // Set a title on the regular thread to trigger a save via handle_thread_update.
1773        cx.update(|cx| {
1774            regular_thread.update(cx, |thread, cx| {
1775                thread.push_user_content_block(None, "content".into(), cx);
1776                thread.set_title("Regular Thread".into(), cx).detach();
1777            });
1778        });
1779        cx.run_until_parked();
1780
1781        // Create a subagent AcpThread
1782        let subagent_session_id = acp::SessionId::new("subagent-session");
1783        let subagent_thread = cx.update(|cx| {
1784            let action_log = cx.new(|_| ActionLog::new(project.clone()));
1785            cx.new(|cx| {
1786                acp_thread::AcpThread::new(
1787                    Some(regular_session_id.clone()),
1788                    Some("Subagent Thread".into()),
1789                    None,
1790                    connection.clone(),
1791                    project.clone(),
1792                    action_log,
1793                    subagent_session_id.clone(),
1794                    watch::Receiver::constant(acp::PromptCapabilities::new()),
1795                    cx,
1796                )
1797            })
1798        });
1799
1800        // Set a title on the subagent thread to trigger handle_thread_update.
1801        cx.update(|cx| {
1802            subagent_thread.update(cx, |thread, cx| {
1803                thread
1804                    .set_title("Subagent Thread Title".into(), cx)
1805                    .detach();
1806            });
1807        });
1808        cx.run_until_parked();
1809
1810        // List all metadata from the store cache.
1811        let list = cx.update(|cx| {
1812            let store = ThreadMetadataStore::global(cx);
1813            store.read(cx).entries().cloned().collect::<Vec<_>>()
1814        });
1815
1816        // The subagent thread should NOT appear in the sidebar metadata.
1817        // Only the regular thread should be listed.
1818        assert_eq!(
1819            list.len(),
1820            1,
1821            "Expected only the regular thread in sidebar metadata, \
1822             but found {} entries (subagent threads are leaking into the sidebar)",
1823            list.len(),
1824        );
1825        assert_eq!(list[0].session_id, regular_session_id);
1826        assert_eq!(list[0].title.as_ref(), "Regular Thread");
1827    }
1828
1829    #[test]
1830    fn test_dedup_db_operations_keeps_latest_operation_for_session() {
1831        let now = Utc::now();
1832
1833        let operations = vec![
1834            DbOperation::Upsert(make_metadata(
1835                "session-1",
1836                "First Thread",
1837                now,
1838                PathList::default(),
1839            )),
1840            DbOperation::Delete(acp::SessionId::new("session-1")),
1841        ];
1842
1843        let deduped = ThreadMetadataStore::dedup_db_operations(operations);
1844
1845        assert_eq!(deduped.len(), 1);
1846        assert_eq!(
1847            deduped[0],
1848            DbOperation::Delete(acp::SessionId::new("session-1"))
1849        );
1850    }
1851
1852    #[test]
1853    fn test_dedup_db_operations_keeps_latest_insert_for_same_session() {
1854        let now = Utc::now();
1855        let later = now + chrono::Duration::seconds(1);
1856
1857        let old_metadata = make_metadata("session-1", "Old Title", now, PathList::default());
1858        let new_metadata = make_metadata("session-1", "New Title", later, PathList::default());
1859
1860        let deduped = ThreadMetadataStore::dedup_db_operations(vec![
1861            DbOperation::Upsert(old_metadata),
1862            DbOperation::Upsert(new_metadata.clone()),
1863        ]);
1864
1865        assert_eq!(deduped.len(), 1);
1866        assert_eq!(deduped[0], DbOperation::Upsert(new_metadata));
1867    }
1868
1869    #[test]
1870    fn test_dedup_db_operations_preserves_distinct_sessions() {
1871        let now = Utc::now();
1872
1873        let metadata1 = make_metadata("session-1", "First Thread", now, PathList::default());
1874        let metadata2 = make_metadata("session-2", "Second Thread", now, PathList::default());
1875        let deduped = ThreadMetadataStore::dedup_db_operations(vec![
1876            DbOperation::Upsert(metadata1.clone()),
1877            DbOperation::Upsert(metadata2.clone()),
1878        ]);
1879
1880        assert_eq!(deduped.len(), 2);
1881        assert!(deduped.contains(&DbOperation::Upsert(metadata1)));
1882        assert!(deduped.contains(&DbOperation::Upsert(metadata2)));
1883    }
1884
1885    #[gpui::test]
1886    async fn test_archive_and_unarchive_thread(cx: &mut TestAppContext) {
1887        init_test(cx);
1888
1889        let paths = PathList::new(&[Path::new("/project-a")]);
1890        let now = Utc::now();
1891        let metadata = make_metadata("session-1", "Thread 1", now, paths.clone());
1892
1893        cx.update(|cx| {
1894            let store = ThreadMetadataStore::global(cx);
1895            store.update(cx, |store, cx| {
1896                store.save(metadata, cx);
1897            });
1898        });
1899
1900        cx.run_until_parked();
1901
1902        cx.update(|cx| {
1903            let store = ThreadMetadataStore::global(cx);
1904            let store = store.read(cx);
1905
1906            let path_entries = store
1907                .entries_for_path(&paths)
1908                .map(|e| e.session_id.0.to_string())
1909                .collect::<Vec<_>>();
1910            assert_eq!(path_entries, vec!["session-1"]);
1911
1912            let archived = store
1913                .archived_entries()
1914                .map(|e| e.session_id.0.to_string())
1915                .collect::<Vec<_>>();
1916            assert!(archived.is_empty());
1917        });
1918
1919        cx.update(|cx| {
1920            let store = ThreadMetadataStore::global(cx);
1921            store.update(cx, |store, cx| {
1922                store.archive(&acp::SessionId::new("session-1"), cx);
1923            });
1924        });
1925
1926        cx.run_until_parked();
1927
1928        cx.update(|cx| {
1929            let store = ThreadMetadataStore::global(cx);
1930            let store = store.read(cx);
1931
1932            let path_entries = store
1933                .entries_for_path(&paths)
1934                .map(|e| e.session_id.0.to_string())
1935                .collect::<Vec<_>>();
1936            assert!(path_entries.is_empty());
1937
1938            let archived = store.archived_entries().collect::<Vec<_>>();
1939            assert_eq!(archived.len(), 1);
1940            assert_eq!(archived[0].session_id.0.as_ref(), "session-1");
1941            assert!(archived[0].archived);
1942        });
1943
1944        cx.update(|cx| {
1945            let store = ThreadMetadataStore::global(cx);
1946            store.update(cx, |store, cx| {
1947                store.unarchive(&acp::SessionId::new("session-1"), cx);
1948            });
1949        });
1950
1951        cx.run_until_parked();
1952
1953        cx.update(|cx| {
1954            let store = ThreadMetadataStore::global(cx);
1955            let store = store.read(cx);
1956
1957            let path_entries = store
1958                .entries_for_path(&paths)
1959                .map(|e| e.session_id.0.to_string())
1960                .collect::<Vec<_>>();
1961            assert_eq!(path_entries, vec!["session-1"]);
1962
1963            let archived = store
1964                .archived_entries()
1965                .map(|e| e.session_id.0.to_string())
1966                .collect::<Vec<_>>();
1967            assert!(archived.is_empty());
1968        });
1969    }
1970
1971    #[gpui::test]
1972    async fn test_entries_for_path_excludes_archived(cx: &mut TestAppContext) {
1973        init_test(cx);
1974
1975        let paths = PathList::new(&[Path::new("/project-a")]);
1976        let now = Utc::now();
1977
1978        let metadata1 = make_metadata("session-1", "Active Thread", now, paths.clone());
1979        let metadata2 = make_metadata(
1980            "session-2",
1981            "Archived Thread",
1982            now - chrono::Duration::seconds(1),
1983            paths.clone(),
1984        );
1985
1986        cx.update(|cx| {
1987            let store = ThreadMetadataStore::global(cx);
1988            store.update(cx, |store, cx| {
1989                store.save(metadata1, cx);
1990                store.save(metadata2, cx);
1991            });
1992        });
1993
1994        cx.run_until_parked();
1995
1996        cx.update(|cx| {
1997            let store = ThreadMetadataStore::global(cx);
1998            store.update(cx, |store, cx| {
1999                store.archive(&acp::SessionId::new("session-2"), cx);
2000            });
2001        });
2002
2003        cx.run_until_parked();
2004
2005        cx.update(|cx| {
2006            let store = ThreadMetadataStore::global(cx);
2007            let store = store.read(cx);
2008
2009            let path_entries = store
2010                .entries_for_path(&paths)
2011                .map(|e| e.session_id.0.to_string())
2012                .collect::<Vec<_>>();
2013            assert_eq!(path_entries, vec!["session-1"]);
2014
2015            let all_entries = store
2016                .entries()
2017                .map(|e| e.session_id.0.to_string())
2018                .collect::<Vec<_>>();
2019            assert_eq!(all_entries.len(), 2);
2020            assert!(all_entries.contains(&"session-1".to_string()));
2021            assert!(all_entries.contains(&"session-2".to_string()));
2022
2023            let archived = store
2024                .archived_entries()
2025                .map(|e| e.session_id.0.to_string())
2026                .collect::<Vec<_>>();
2027            assert_eq!(archived, vec!["session-2"]);
2028        });
2029    }
2030
2031    #[gpui::test]
2032    async fn test_save_all_persists_multiple_threads(cx: &mut TestAppContext) {
2033        init_test(cx);
2034
2035        let paths = PathList::new(&[Path::new("/project-a")]);
2036        let now = Utc::now();
2037
2038        let m1 = make_metadata("session-1", "Thread One", now, paths.clone());
2039        let m2 = make_metadata(
2040            "session-2",
2041            "Thread Two",
2042            now - chrono::Duration::seconds(1),
2043            paths.clone(),
2044        );
2045        let m3 = make_metadata(
2046            "session-3",
2047            "Thread Three",
2048            now - chrono::Duration::seconds(2),
2049            paths,
2050        );
2051
2052        cx.update(|cx| {
2053            let store = ThreadMetadataStore::global(cx);
2054            store.update(cx, |store, cx| {
2055                store.save_all(vec![m1, m2, m3], cx);
2056            });
2057        });
2058
2059        cx.run_until_parked();
2060
2061        cx.update(|cx| {
2062            let store = ThreadMetadataStore::global(cx);
2063            let store = store.read(cx);
2064
2065            let all_entries = store
2066                .entries()
2067                .map(|e| e.session_id.0.to_string())
2068                .collect::<Vec<_>>();
2069            assert_eq!(all_entries.len(), 3);
2070            assert!(all_entries.contains(&"session-1".to_string()));
2071            assert!(all_entries.contains(&"session-2".to_string()));
2072            assert!(all_entries.contains(&"session-3".to_string()));
2073
2074            let entry_ids = store.entry_ids().collect::<Vec<_>>();
2075            assert_eq!(entry_ids.len(), 3);
2076        });
2077    }
2078
2079    #[gpui::test]
2080    async fn test_archived_flag_persists_across_reload(cx: &mut TestAppContext) {
2081        init_test(cx);
2082
2083        let paths = PathList::new(&[Path::new("/project-a")]);
2084        let now = Utc::now();
2085        let metadata = make_metadata("session-1", "Thread 1", now, paths.clone());
2086
2087        cx.update(|cx| {
2088            let store = ThreadMetadataStore::global(cx);
2089            store.update(cx, |store, cx| {
2090                store.save(metadata, cx);
2091            });
2092        });
2093
2094        cx.run_until_parked();
2095
2096        cx.update(|cx| {
2097            let store = ThreadMetadataStore::global(cx);
2098            store.update(cx, |store, cx| {
2099                store.archive(&acp::SessionId::new("session-1"), cx);
2100            });
2101        });
2102
2103        cx.run_until_parked();
2104
2105        cx.update(|cx| {
2106            let store = ThreadMetadataStore::global(cx);
2107            store.update(cx, |store, cx| {
2108                let _ = store.reload(cx);
2109            });
2110        });
2111
2112        cx.run_until_parked();
2113
2114        cx.update(|cx| {
2115            let store = ThreadMetadataStore::global(cx);
2116            let store = store.read(cx);
2117
2118            let thread = store
2119                .entries()
2120                .find(|e| e.session_id.0.as_ref() == "session-1")
2121                .expect("thread should exist after reload");
2122            assert!(thread.archived);
2123
2124            let path_entries = store
2125                .entries_for_path(&paths)
2126                .map(|e| e.session_id.0.to_string())
2127                .collect::<Vec<_>>();
2128            assert!(path_entries.is_empty());
2129
2130            let archived = store
2131                .archived_entries()
2132                .map(|e| e.session_id.0.to_string())
2133                .collect::<Vec<_>>();
2134            assert_eq!(archived, vec!["session-1"]);
2135        });
2136    }
2137
2138    #[gpui::test]
2139    async fn test_archive_nonexistent_thread_is_noop(cx: &mut TestAppContext) {
2140        init_test(cx);
2141
2142        cx.run_until_parked();
2143
2144        cx.update(|cx| {
2145            let store = ThreadMetadataStore::global(cx);
2146            store.update(cx, |store, cx| {
2147                store.archive(&acp::SessionId::new("nonexistent"), cx);
2148            });
2149        });
2150
2151        cx.run_until_parked();
2152
2153        cx.update(|cx| {
2154            let store = ThreadMetadataStore::global(cx);
2155            let store = store.read(cx);
2156
2157            assert!(store.is_empty());
2158            assert_eq!(store.entries().count(), 0);
2159            assert_eq!(store.archived_entries().count(), 0);
2160        });
2161    }
2162
2163    #[gpui::test]
2164    async fn test_save_followed_by_archiving_without_parking(cx: &mut TestAppContext) {
2165        init_test(cx);
2166
2167        let paths = PathList::new(&[Path::new("/project-a")]);
2168        let now = Utc::now();
2169        let metadata = make_metadata("session-1", "Thread 1", now, paths);
2170        let session_id = metadata.session_id.clone();
2171
2172        cx.update(|cx| {
2173            let store = ThreadMetadataStore::global(cx);
2174            store.update(cx, |store, cx| {
2175                store.save(metadata.clone(), cx);
2176                store.archive(&session_id, cx);
2177            });
2178        });
2179
2180        cx.run_until_parked();
2181
2182        cx.update(|cx| {
2183            let store = ThreadMetadataStore::global(cx);
2184            let store = store.read(cx);
2185
2186            let entries: Vec<ThreadMetadata> = store.entries().cloned().collect();
2187            pretty_assertions::assert_eq!(
2188                entries,
2189                vec![ThreadMetadata {
2190                    archived: true,
2191                    ..metadata
2192                }]
2193            );
2194        });
2195    }
2196}