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