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