thread_metadata_store.rs

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