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