Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:51:08.152Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

thread_metadata_store.rs

4279 lines · 157.4 KB · rust
1use std::{
2    path::{Path, PathBuf},
3    sync::Arc,
4};
5
6use agent::{ThreadStore, OMEGA_AGENT_ID};
7use agent_client_protocol::schema::v1 as acp;
8use anyhow::Context as _;
9use chrono::{DateTime, Utc};
10use collections::{HashMap, HashSet};
11use db::{
12    kvp::KeyValueStore,
13    sqlez::{
14        bindable::{Bind, Column},
15        domain::Domain,
16        statement::Statement,
17        thread_safe_connection::ThreadSafeConnection,
18    },
19    sqlez_macros::sql,
20};
21use fs::Fs;
22use futures::{FutureExt, future::Shared};
23use gpui::{AppContext as _, Entity, Global, Subscription, Task, TaskExt};
24pub use project::WorktreePaths;
25use project::{AgentId, linked_worktree_short_name};
26use remote::{RemoteConnectionOptions, same_remote_connection_identity};
27use ui::{App, Context, SharedString, ThreadItemWorktreeInfo, WorktreeKind};
28use util::ResultExt as _;
29use workspace::{PathList, SerializedWorkspaceLocation, WorkspaceDb};
30
31use crate::DEFAULT_THREAD_TITLE;
32
33#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, serde::Serialize, serde::Deserialize)]
34pub struct ThreadId(uuid::Uuid);
35
36impl ThreadId {
37    pub fn new() -> Self {
38        Self(uuid::Uuid::new_v4())
39    }
40
41    /// Stable, hyphenated string form suitable for use as a key.
42    pub fn to_key_string(&self) -> String {
43        self.0.hyphenated().to_string()
44    }
45
46    /// Parses the stable key form emitted by [`Self::to_key_string`].
47    pub fn from_key_string(key: &str) -> anyhow::Result<Self> {
48        Ok(Self(uuid::Uuid::parse_str(key)?))
49    }
50}
51
52impl Bind for ThreadId {
53    fn bind(&self, statement: &Statement, start_index: i32) -> anyhow::Result<i32> {
54        self.0.bind(statement, start_index)
55    }
56}
57
58impl Column for ThreadId {
59    fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
60        let (uuid, next) = Column::column(statement, start_index)?;
61        Ok((ThreadId(uuid), next))
62    }
63}
64
65const THREAD_REMOTE_CONNECTION_MIGRATION_KEY: &str = "thread-metadata-remote-connection-backfill";
66const THREAD_ID_MIGRATION_KEY: &str = "thread-metadata-thread-id-backfill";
67
68/// List all sidebar thread metadata from an arbitrary SQLite connection.
69///
70/// This is used to read thread metadata from another release channel's
71/// database without opening a full `ThreadSafeConnection`.
72pub(crate) fn list_thread_metadata_from_connection(
73    connection: &db::sqlez::connection::Connection,
74) -> anyhow::Result<Vec<ThreadMetadata>> {
75    connection.select::<ThreadMetadata>(ThreadMetadataDb::LIST_QUERY)?()
76}
77
78/// Run the `ThreadMetadataDb` migrations on a raw connection.
79///
80/// This is used in tests to set up the sidebar_threads schema in a
81/// temporary database.
82#[cfg(test)]
83pub(crate) fn run_thread_metadata_migrations(connection: &db::sqlez::connection::Connection) {
84    connection
85        .migrate(
86            ThreadMetadataDb::NAME,
87            ThreadMetadataDb::MIGRATIONS,
88            &mut |_, _, _| false,
89        )
90        .expect("thread metadata migrations should succeed");
91}
92
93pub fn init(cx: &mut App) {
94    ThreadMetadataStore::init_global(cx);
95    let migration_task = migrate_thread_metadata(cx);
96    migrate_thread_remote_connections(cx, migration_task);
97    migrate_thread_ids(cx);
98}
99
100/// Migrate existing thread metadata from native agent thread store to the new metadata storage.
101/// We skip migrating threads that do not have a project.
102///
103/// TODO: Remove this after N weeks of shipping the sidebar
104fn migrate_thread_metadata(cx: &mut App) -> Task<anyhow::Result<()>> {
105    let store = ThreadMetadataStore::global(cx);
106    let db = store.read(cx).db.clone();
107    let thread_store = ThreadStore::global(cx);
108    let thread_store_ready = thread_store.read(cx).reload_task();
109
110    cx.spawn(async move |cx| {
111        // Wait for `ThreadStore`'s initial reload to complete. Without this,
112        // reading `entries()` races with the store's async population from
113        // disk and usually observes an empty iterator, silently skipping the
114        // migration on every launch. The regression test
115        // `test_migration_awaits_thread_store_reload` pins this behavior.
116        thread_store_ready.await;
117
118        let existing_list = db.list()?;
119        let existing_session_ids: HashSet<Arc<str>> = existing_list
120            .into_iter()
121            .filter_map(|m| m.session_id.map(|s| s.0))
122            .collect();
123
124        let mut to_migrate = thread_store.read_with(cx, |store, _cx| {
125            store
126                .entries()
127                .filter_map(|entry| {
128                    if existing_session_ids.contains(&entry.id.0) {
129                        return None;
130                    }
131
132                    Some(ThreadMetadata {
133                        thread_id: ThreadId::new(),
134                        session_id: Some(entry.id),
135                        agent_id: OMEGA_AGENT_ID.clone(),
136                        title: if entry.title.is_empty()
137                            || entry.title.as_ref() == DEFAULT_THREAD_TITLE
138                        {
139                            None
140                        } else {
141                            Some(entry.title)
142                        },
143                        title_override: None,
144                        updated_at: entry.updated_at,
145                        created_at: entry.created_at,
146                        interacted_at: None,
147                        worktree_paths: WorktreePaths::from_folder_paths(&entry.folder_paths),
148                        remote_connection: None,
149                        archived: true,
150                    })
151                })
152                .collect::<Vec<_>>()
153        });
154
155        if to_migrate.is_empty() {
156            return anyhow::Ok(());
157        }
158
159        // For each batch of newly-migrated threads, keep the 5 most recent
160        // per project unarchived. Previously this was gated on
161        // `is_first_migration` (an empty `sidebar_threads`), which meant any
162        // subsequent batch of newly-discovered legacy threads got migrated as
163        // fully archived. Running the rescue per-batch keeps the behavior
164        // idempotent across partial migrations and re-runs.
165        let mut per_project: HashMap<PathList, Vec<&mut ThreadMetadata>> = HashMap::default();
166        for entry in &mut to_migrate {
167            if entry.worktree_paths.is_empty() {
168                continue;
169            }
170            per_project
171                .entry(entry.worktree_paths.folder_path_list().clone())
172                .or_default()
173                .push(entry);
174        }
175        for entries in per_project.values_mut() {
176            entries.sort_by_key(|entry| std::cmp::Reverse(entry.updated_at));
177            for entry in entries.iter_mut().take(5) {
178                entry.archived = false;
179            }
180        }
181
182        log::info!("Migrating {} thread store entries", to_migrate.len());
183
184        // Manually save each entry to the database and call reload, otherwise
185        // we'll end up triggering lots of reloads after each save
186        for entry in to_migrate {
187            db.save(entry).await?;
188        }
189
190        log::info!("Finished migrating thread store entries");
191
192        let _ = store.update(cx, |store, cx| store.reload(cx));
193        anyhow::Ok(())
194    })
195}
196
197fn migrate_thread_remote_connections(cx: &mut App, migration_task: Task<anyhow::Result<()>>) {
198    let store = ThreadMetadataStore::global(cx);
199    let db = store.read(cx).db.clone();
200    let kvp = KeyValueStore::global(cx);
201    let workspace_db = WorkspaceDb::global(cx);
202    let fs = <dyn Fs>::global(cx);
203
204    cx.spawn(async move |cx| -> anyhow::Result<()> {
205        migration_task.await?;
206
207        if kvp
208            .read_kvp(THREAD_REMOTE_CONNECTION_MIGRATION_KEY)?
209            .is_some()
210        {
211            return Ok(());
212        }
213
214        let recent_workspaces = workspace_db
215            .recent_project_workspaces_ungrouped(fs.as_ref())
216            .await?;
217
218        let mut local_path_lists = HashSet::<PathList>::default();
219        let mut remote_path_lists = HashMap::<PathList, RemoteConnectionOptions>::default();
220
221        recent_workspaces
222            .iter()
223            .filter(|workspace| {
224                !workspace.paths.is_empty()
225                    && matches!(workspace.location, SerializedWorkspaceLocation::Local)
226            })
227            .for_each(|workspace| {
228                local_path_lists.insert(workspace.paths.clone());
229            });
230
231        for workspace in recent_workspaces {
232            match workspace.location {
233                SerializedWorkspaceLocation::Remote(remote_connection)
234                    if !local_path_lists.contains(&workspace.paths) =>
235                {
236                    remote_path_lists
237                        .entry(workspace.paths)
238                        .or_insert(remote_connection);
239                }
240                _ => {}
241            }
242        }
243
244        let mut reloaded = false;
245        for metadata in db.list()? {
246            if metadata.remote_connection.is_some() {
247                continue;
248            }
249
250            if let Some(remote_connection) = remote_path_lists
251                .get(metadata.folder_paths())
252                .or_else(|| remote_path_lists.get(metadata.main_worktree_paths()))
253            {
254                db.save(ThreadMetadata {
255                    remote_connection: Some(remote_connection.clone()),
256                    ..metadata
257                })
258                .await?;
259                reloaded = true;
260            }
261        }
262
263        let reloaded_task = reloaded
264            .then_some(store.update(cx, |store, cx| store.reload(cx)))
265            .unwrap_or(Task::ready(()).shared());
266
267        kvp.write_kvp(
268            THREAD_REMOTE_CONNECTION_MIGRATION_KEY.to_string(),
269            "1".to_string(),
270        )
271        .await?;
272        reloaded_task.await;
273
274        Ok(())
275    })
276    .detach_and_log_err(cx);
277}
278
279fn migrate_thread_ids(cx: &mut App) {
280    let store = ThreadMetadataStore::global(cx);
281    let db = store.read(cx).db.clone();
282    let kvp = KeyValueStore::global(cx);
283
284    cx.spawn(async move |cx| -> anyhow::Result<()> {
285        if kvp.read_kvp(THREAD_ID_MIGRATION_KEY)?.is_some() {
286            return Ok(());
287        }
288
289        let mut reloaded = false;
290        for metadata in db.list()? {
291            db.save(metadata).await?;
292            reloaded = true;
293        }
294
295        let reloaded_task = reloaded
296            .then_some(store.update(cx, |store, cx| store.reload(cx)))
297            .unwrap_or(Task::ready(()).shared());
298
299        kvp.write_kvp(THREAD_ID_MIGRATION_KEY.to_string(), "1".to_string())
300            .await?;
301        reloaded_task.await;
302
303        Ok(())
304    })
305    .detach_and_log_err(cx);
306}
307
308struct GlobalThreadMetadataStore(Entity<ThreadMetadataStore>);
309impl Global for GlobalThreadMetadataStore {}
310
311/// Lightweight metadata for any thread (native or ACP), enough to populate
312/// the sidebar list and route to the correct load path when clicked.
313#[derive(Debug, Clone, PartialEq)]
314pub struct ThreadMetadata {
315    pub thread_id: ThreadId,
316    pub session_id: Option<acp::SessionId>,
317    pub agent_id: AgentId,
318    pub title: Option<SharedString>,
319    /// User-supplied title that takes precedence over `title`. Set when the
320    /// user renames a thread, so that subsequent agent-driven title updates
321    /// (e.g. from `SessionInfoUpdate`) don't clobber the user's choice.
322    pub title_override: Option<SharedString>,
323    pub updated_at: DateTime<Utc>,
324    pub created_at: Option<DateTime<Utc>>,
325    /// When a user last interacted to send a message (including queueing).
326    /// Doesn't include the time when a queued message is fired.
327    pub interacted_at: Option<DateTime<Utc>>,
328    pub worktree_paths: WorktreePaths,
329    pub remote_connection: Option<RemoteConnectionOptions>,
330    pub archived: bool,
331}
332
333impl ThreadMetadata {
334    /// A thread is a draft until its first message is sent, at which point
335    /// it gets an ACP `session_id`.
336    pub fn is_draft(&self) -> bool {
337        self.session_id.is_none()
338    }
339
340    pub fn display_title(&self) -> SharedString {
341        self.title()
342            .unwrap_or_else(|| crate::DEFAULT_THREAD_TITLE.into())
343    }
344
345    pub fn title(&self) -> Option<SharedString> {
346        self.title_override.clone().or_else(|| self.title.clone())
347    }
348
349    pub fn folder_paths(&self) -> &PathList {
350        self.worktree_paths.folder_path_list()
351    }
352    pub fn main_worktree_paths(&self) -> &PathList {
353        self.worktree_paths.main_worktree_path_list()
354    }
355
356    pub fn references_folder_path(&self, path: &Path) -> bool {
357        self.folder_paths()
358            .paths()
359            .iter()
360            .any(|folder_path| folder_path.as_path() == path)
361    }
362
363    pub fn matches_remote_connection(
364        &self,
365        remote_connection: Option<&RemoteConnectionOptions>,
366    ) -> bool {
367        same_remote_connection_identity(self.remote_connection.as_ref(), remote_connection)
368    }
369}
370
371/// Derives worktree display info from a thread's stored path list.
372///
373/// For each path in the thread's `folder_paths`, produces a
374/// [`ThreadItemWorktreeInfo`] with a short display name, full path, and whether
375/// the worktree is the main checkout or a linked git worktree. When
376/// multiple main paths exist and a linked worktree's short name alone
377/// wouldn't identify which main project it belongs to, the main project
378/// name is prefixed for disambiguation (e.g. `project:feature`).
379pub fn worktree_info_from_thread_paths<S: std::hash::BuildHasher>(
380    worktree_paths: &WorktreePaths,
381    branch_names: &std::collections::HashMap<PathBuf, SharedString, S>,
382) -> Vec<ThreadItemWorktreeInfo> {
383    let mut infos: Vec<ThreadItemWorktreeInfo> = Vec::new();
384    let mut linked_short_names: Vec<(SharedString, SharedString)> = Vec::new();
385    let mut unique_main_count = HashSet::default();
386
387    for (main_path, folder_path) in worktree_paths.ordered_pairs() {
388        unique_main_count.insert(main_path.clone());
389        let is_linked = main_path != folder_path;
390
391        if is_linked {
392            let short_name = linked_worktree_short_name(main_path, folder_path).unwrap_or_default();
393            let project_name = main_path
394                .file_name()
395                .map(|n| SharedString::from(n.to_string_lossy().to_string()))
396                .unwrap_or_default();
397            linked_short_names.push((short_name.clone(), project_name));
398            infos.push(ThreadItemWorktreeInfo {
399                worktree_name: Some(short_name),
400                full_path: SharedString::from(folder_path.display().to_string()),
401                highlight_positions: Vec::new(),
402                kind: WorktreeKind::Linked,
403                branch_name: branch_names.get(folder_path).cloned(),
404            });
405        } else {
406            let Some(name) = folder_path.file_name() else {
407                continue;
408            };
409            infos.push(ThreadItemWorktreeInfo {
410                worktree_name: Some(SharedString::from(name.to_string_lossy().to_string())),
411                full_path: SharedString::from(folder_path.display().to_string()),
412                highlight_positions: Vec::new(),
413                kind: WorktreeKind::Main,
414                branch_name: branch_names.get(folder_path).cloned(),
415            });
416        }
417    }
418
419    // When the group has multiple main worktree paths and the thread's
420    // folder paths don't all share the same short name, prefix each
421    // linked worktree chip with its main project name so the user knows
422    // which project it belongs to.
423    let all_same_name = infos.len() > 1
424        && infos
425            .iter()
426            .all(|i| i.worktree_name == infos[0].worktree_name);
427
428    if unique_main_count.len() > 1 && !all_same_name {
429        for (info, (_short_name, project_name)) in infos
430            .iter_mut()
431            .filter(|i| i.kind == WorktreeKind::Linked)
432            .zip(linked_short_names.iter())
433        {
434            if let Some(name) = &info.worktree_name {
435                info.worktree_name = Some(SharedString::from(format!("{}:{}", project_name, name)));
436            }
437        }
438    }
439
440    infos
441}
442
443impl From<&ThreadMetadata> for acp_thread::AgentSessionInfo {
444    fn from(meta: &ThreadMetadata) -> Self {
445        let session_id = meta
446            .session_id
447            .clone()
448            .unwrap_or_else(|| acp::SessionId::new(meta.thread_id.0.to_string()));
449        Self {
450            session_id,
451            work_dirs: Some(meta.folder_paths().clone()),
452            title: meta.title(),
453            updated_at: Some(meta.updated_at),
454            created_at: meta.created_at,
455            meta: None,
456        }
457    }
458}
459
460/// Record of a git worktree that was archived (deleted from disk) when its
461/// last thread was archived.
462pub struct ArchivedGitWorktree {
463    /// Auto-incrementing primary key.
464    pub id: i64,
465    /// Absolute path to the directory of the worktree before it was deleted.
466    /// Used when restoring, to put the recreated worktree back where it was.
467    /// If the path already exists on disk, the worktree is assumed to be
468    /// already restored and is used as-is.
469    pub worktree_path: PathBuf,
470    /// Absolute path of the main repository ("main worktree") that owned this worktree.
471    /// Used when restoring, to reattach the recreated worktree to the correct main repo.
472    /// If the main repo isn't found on disk, unarchiving fails because we only store
473    /// commit hashes, and without the actual git repo being available, we can't restore
474    /// the files.
475    pub main_repo_path: PathBuf,
476    /// Branch that was checked out in the worktree at archive time. `None` if
477    /// the worktree was in detached HEAD state, which isn't supported in Zed, but
478    /// could happen if the user made a detached one outside of Zed.
479    /// On restore, we try to switch to this branch. If that fails (e.g. it's
480    /// checked out elsewhere), we auto-generate a new one.
481    pub branch_name: Option<String>,
482    /// SHA of the WIP commit that captures files that were staged (but not yet
483    /// committed) at the time of archiving. This commit can be empty if the
484    /// user had no staged files at the time. It sits directly on top of whatever
485    /// the user's last actual commit was.
486    pub staged_commit_hash: String,
487    /// SHA of the WIP commit that captures files that were unstaged (including
488    /// untracked) at the time of archiving. This commit can be empty if the user
489    /// had no unstaged files at the time. It sits on top of `staged_commit_hash`.
490    /// After doing `git reset` past both of these commits, we're back in the state
491    /// we had before archiving, including what was staged, what was unstaged, and
492    /// what was committed.
493    pub unstaged_commit_hash: String,
494    /// SHA of the commit that HEAD pointed at before we created the two WIP
495    /// commits during archival. After resetting past the WIP commits during
496    /// restore, HEAD should land back on this commit. It also serves as a
497    /// pre-restore sanity check (abort if this commit no longer exists in the
498    /// repo) and as a fallback target if the WIP resets fail.
499    pub original_commit_hash: String,
500}
501
502/// The store holds all metadata needed to show threads in the sidebar/the archive.
503///
504/// Listens to ConversationView events and updates metadata when the root thread changes.
505pub struct ThreadMetadataStore {
506    db: ThreadMetadataDb,
507    threads: HashMap<ThreadId, ThreadMetadata>,
508    threads_by_paths: HashMap<PathList, HashSet<ThreadId>>,
509    threads_by_main_paths: HashMap<PathList, HashSet<ThreadId>>,
510    threads_by_session: HashMap<acp::SessionId, ThreadId>,
511    reload_task: Option<Shared<Task<()>>>,
512    conversation_subscriptions: HashMap<gpui::EntityId, Subscription>,
513    pending_thread_ops_tx: async_channel::Sender<DbOperation>,
514    in_flight_archives: HashMap<ThreadId, (Task<()>, async_channel::Sender<()>)>,
515    _db_operations_task: Task<()>,
516}
517
518#[derive(Debug, PartialEq)]
519enum DbOperation {
520    Upsert(ThreadMetadata),
521    Delete(ThreadId),
522}
523
524impl DbOperation {
525    fn id(&self) -> ThreadId {
526        match self {
527            DbOperation::Upsert(thread) => thread.thread_id,
528            DbOperation::Delete(thread_id) => *thread_id,
529        }
530    }
531}
532
533/// Override for the test DB name used by `ThreadMetadataStore::init_global`.
534/// When set as a GPUI global, `init_global` uses this name instead of
535/// deriving one from the thread name. This prevents data from leaking
536/// across proptest cases that share a thread name.
537#[cfg(any(test, feature = "test-support"))]
538pub struct TestMetadataDbName(pub String);
539#[cfg(any(test, feature = "test-support"))]
540impl gpui::Global for TestMetadataDbName {}
541
542#[cfg(any(test, feature = "test-support"))]
543impl TestMetadataDbName {
544    pub fn global(cx: &App) -> String {
545        cx.try_global::<Self>()
546            .map(|g| g.0.clone())
547            .unwrap_or_else(|| {
548                let thread = std::thread::current();
549                let test_name = thread.name().unwrap_or("unknown_test");
550                format!("THREAD_METADATA_DB_{}", test_name)
551            })
552    }
553}
554
555impl ThreadMetadataStore {
556    #[cfg(not(any(test, feature = "test-support")))]
557    pub fn init_global(cx: &mut App) {
558        if cx.has_global::<Self>() {
559            return;
560        }
561
562        let db = ThreadMetadataDb::global(cx);
563        let thread_store = cx.new(|cx| Self::new(db, cx));
564        cx.set_global(GlobalThreadMetadataStore(thread_store));
565    }
566
567    #[cfg(any(test, feature = "test-support"))]
568    pub fn init_global(cx: &mut App) {
569        let db_name = TestMetadataDbName::global(cx);
570        let db = gpui::block_on(db::open_test_db::<ThreadMetadataDb>(&db_name));
571        let thread_store = cx.new(|cx| Self::new(ThreadMetadataDb(db), cx));
572        cx.set_global(GlobalThreadMetadataStore(thread_store));
573    }
574
575    pub fn try_global(cx: &App) -> Option<Entity<Self>> {
576        cx.try_global::<GlobalThreadMetadataStore>()
577            .map(|store| store.0.clone())
578    }
579
580    pub fn global(cx: &App) -> Entity<Self> {
581        cx.global::<GlobalThreadMetadataStore>().0.clone()
582    }
583
584    pub fn is_empty(&self) -> bool {
585        self.threads.is_empty()
586    }
587
588    /// Returns all thread IDs.
589    pub fn entry_ids(&self) -> impl Iterator<Item = ThreadId> + '_ {
590        self.threads.keys().copied()
591    }
592
593    /// Returns the metadata for a specific thread, if it exists.
594    pub fn entry(&self, thread_id: ThreadId) -> Option<&ThreadMetadata> {
595        self.threads.get(&thread_id)
596    }
597
598    /// Returns the metadata for a thread identified by its ACP session ID.
599    pub fn entry_by_session(&self, session_id: &acp::SessionId) -> Option<&ThreadMetadata> {
600        let thread_id = self.threads_by_session.get(session_id)?;
601        self.threads.get(thread_id)
602    }
603
604    /// Returns all threads.
605    pub fn entries(&self) -> impl Iterator<Item = &ThreadMetadata> + '_ {
606        self.threads.values()
607    }
608
609    pub fn reload_task(&self) -> Shared<Task<()>> {
610        self.reload_task
611            .clone()
612            .unwrap_or_else(|| Task::ready(()).shared())
613    }
614
615    /// Returns all archived threads.
616    pub fn archived_entries(&self) -> impl Iterator<Item = &ThreadMetadata> + '_ {
617        self.entries().filter(|t| t.archived)
618    }
619
620    /// Returns all threads for the given path list and remote connection,
621    /// excluding archived threads.
622    ///
623    /// When `remote_connection` is `Some`, only threads whose persisted
624    /// `remote_connection` matches by normalized identity are returned.
625    /// When `None`, only local (non-remote) threads are returned.
626    pub fn entries_for_path<'a>(
627        &'a self,
628        path_list: &PathList,
629        remote_connection: Option<&'a RemoteConnectionOptions>,
630    ) -> impl Iterator<Item = &'a ThreadMetadata> + 'a {
631        self.threads_by_paths
632            .get(path_list)
633            .into_iter()
634            .flatten()
635            .filter_map(|s| self.threads.get(s))
636            .filter(|s| !s.archived)
637            .filter(move |s| s.matches_remote_connection(remote_connection))
638    }
639
640    /// Returns threads whose `main_worktree_paths` matches the given path list
641    /// and remote connection, excluding archived threads. This finds threads
642    /// that were opened in a linked worktree but are associated with the given
643    /// main worktree.
644    ///
645    /// When `remote_connection` is `Some`, only threads whose persisted
646    /// `remote_connection` matches by normalized identity are returned.
647    /// When `None`, only local (non-remote) threads are returned.
648    pub fn entries_for_main_worktree_path<'a>(
649        &'a self,
650        path_list: &PathList,
651        remote_connection: Option<&'a RemoteConnectionOptions>,
652    ) -> impl Iterator<Item = &'a ThreadMetadata> + 'a {
653        self.threads_by_main_paths
654            .get(path_list)
655            .into_iter()
656            .flatten()
657            .filter_map(|s| self.threads.get(s))
658            .filter(|s| !s.archived)
659            .filter(move |s| s.matches_remote_connection(remote_connection))
660    }
661
662    fn reload(&mut self, cx: &mut Context<Self>) -> Shared<Task<()>> {
663        let db = self.db.clone();
664        self.reload_task.take();
665
666        let list_task = cx
667            .background_spawn(async move { db.list().context("Failed to fetch sidebar metadata") });
668
669        let reload_task = cx
670            .spawn(async move |this, cx| {
671                let Some(rows) = list_task.await.log_err() else {
672                    return;
673                };
674
675                this.update(cx, |this, cx| {
676                    this.threads.clear();
677                    this.threads_by_paths.clear();
678                    this.threads_by_main_paths.clear();
679                    this.threads_by_session.clear();
680
681                    for row in rows {
682                        this.cache_thread_metadata(row);
683                    }
684
685                    cx.notify();
686                })
687                .ok();
688            })
689            .shared();
690        self.reload_task = Some(reload_task.clone());
691        reload_task
692    }
693
694    pub fn save_all(&mut self, metadata: Vec<ThreadMetadata>, cx: &mut Context<Self>) {
695        for metadata in metadata {
696            self.save_internal(metadata);
697        }
698        cx.notify();
699    }
700
701    pub fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context<Self>) {
702        self.save_internal(metadata);
703        cx.notify();
704    }
705
706    /// Set or clear the user-supplied title for a thread.
707    pub fn set_title_override(
708        &mut self,
709        thread_id: ThreadId,
710        title_override: SharedString,
711        cx: &mut Context<Self>,
712    ) {
713        let Some(existing) = self.entry(thread_id) else {
714            return;
715        };
716        if existing.title_override.as_ref() == Some(&title_override) {
717            return;
718        }
719        let metadata = ThreadMetadata {
720            title_override: Some(title_override),
721            ..existing.clone()
722        };
723        self.save(metadata, cx);
724    }
725
726    pub fn set_generated_title(
727        &mut self,
728        thread_id: ThreadId,
729        title: SharedString,
730        cx: &mut Context<Self>,
731    ) {
732        let Some(existing) = self.entry(thread_id) else {
733            return;
734        };
735        if existing.title.as_ref() == Some(&title) && existing.title_override.is_none() {
736            return;
737        }
738        let metadata = ThreadMetadata {
739            title: Some(title),
740            title_override: None,
741            ..existing.clone()
742        };
743        self.save(metadata, cx);
744    }
745
746    fn save_internal(&mut self, metadata: ThreadMetadata) {
747        if let Some(thread) = self.threads.get(&metadata.thread_id) {
748            if thread.folder_paths() != metadata.folder_paths() {
749                if let Some(thread_ids) = self.threads_by_paths.get_mut(thread.folder_paths()) {
750                    thread_ids.remove(&metadata.thread_id);
751                }
752            }
753            if thread.main_worktree_paths() != metadata.main_worktree_paths()
754                && !thread.main_worktree_paths().is_empty()
755            {
756                if let Some(thread_ids) = self
757                    .threads_by_main_paths
758                    .get_mut(thread.main_worktree_paths())
759                {
760                    thread_ids.remove(&metadata.thread_id);
761                }
762            }
763        }
764
765        self.cache_thread_metadata(metadata.clone());
766        self.pending_thread_ops_tx
767            .try_send(DbOperation::Upsert(metadata))
768            .log_err();
769    }
770
771    fn cache_thread_metadata(&mut self, metadata: ThreadMetadata) {
772        // Drafts may not have a session_id yet; only index by session
773        // when one is present.
774        if let Some(session_id) = metadata.session_id.as_ref() {
775            self.threads_by_session
776                .insert(session_id.clone(), metadata.thread_id);
777        }
778
779        self.threads.insert(metadata.thread_id, metadata.clone());
780
781        self.threads_by_paths
782            .entry(metadata.folder_paths().clone())
783            .or_default()
784            .insert(metadata.thread_id);
785
786        if !metadata.main_worktree_paths().is_empty() {
787            self.threads_by_main_paths
788                .entry(metadata.main_worktree_paths().clone())
789                .or_default()
790                .insert(metadata.thread_id);
791        }
792    }
793
794    pub fn update_working_directories(
795        &mut self,
796        thread_id: ThreadId,
797        work_dirs: PathList,
798        cx: &mut Context<Self>,
799    ) {
800        if let Some(thread) = self.threads.get(&thread_id) {
801            debug_assert!(
802                !thread.archived,
803                "update_working_directories called on archived thread"
804            );
805            self.save_internal(ThreadMetadata {
806                worktree_paths: WorktreePaths::from_path_lists(
807                    thread.main_worktree_paths().clone(),
808                    work_dirs.clone(),
809                )
810                .unwrap_or_else(|_| WorktreePaths::from_folder_paths(&work_dirs)),
811                ..thread.clone()
812            });
813            cx.notify();
814        }
815    }
816
817    pub fn update_worktree_paths(
818        &mut self,
819        thread_ids: &[ThreadId],
820        worktree_paths: WorktreePaths,
821        cx: &mut Context<Self>,
822    ) {
823        let mut changed = false;
824        for &thread_id in thread_ids {
825            let Some(thread) = self.threads.get(&thread_id) else {
826                continue;
827            };
828            if thread.worktree_paths == worktree_paths {
829                continue;
830            }
831            // Don't overwrite paths for archived threads — the
832            // project may no longer include the worktree that was
833            // removed during the archive flow.
834            if thread.archived {
835                continue;
836            }
837            self.save_internal(ThreadMetadata {
838                worktree_paths: worktree_paths.clone(),
839                ..thread.clone()
840            });
841            changed = true;
842        }
843        if changed {
844            cx.notify();
845        }
846    }
847
848    pub fn update_interacted_at(
849        &mut self,
850        thread_id: &ThreadId,
851        time: DateTime<Utc>,
852        cx: &mut Context<Self>,
853    ) {
854        if let Some(thread) = self.threads.get(thread_id) {
855            self.save_internal(ThreadMetadata {
856                interacted_at: Some(time),
857                ..thread.clone()
858            });
859            cx.notify();
860        };
861    }
862
863    pub fn archive(
864        &mut self,
865        thread_id: ThreadId,
866        archive_job: Option<(Task<()>, async_channel::Sender<()>)>,
867        cx: &mut Context<Self>,
868    ) {
869        self.update_archived(thread_id, true, cx);
870
871        if let Some(job) = archive_job {
872            self.in_flight_archives.insert(thread_id, job);
873        }
874
875        cx.emit(ThreadMetadataStoreEvent::ThreadArchived(thread_id));
876    }
877
878    pub fn unarchive(&mut self, thread_id: ThreadId, cx: &mut Context<Self>) {
879        self.update_archived(thread_id, false, cx);
880        // Dropping the Sender triggers cancellation in the background task.
881        self.in_flight_archives.remove(&thread_id);
882    }
883
884    pub fn cleanup_completed_archive(&mut self, thread_id: ThreadId) {
885        self.in_flight_archives.remove(&thread_id);
886    }
887
888    /// Returns `true` if any unarchived thread other than `thread_id`
889    /// references `path` in its folder paths. Used to determine whether a
890    /// worktree can safely be removed from disk.
891    pub fn path_is_referenced_by_unarchived_threads(
892        &self,
893        thread_id: Option<ThreadId>,
894        path: &Path,
895        remote_connection: Option<&RemoteConnectionOptions>,
896    ) -> bool {
897        self.path_is_referenced_by_unarchived_threads_matching(
898            thread_id,
899            path,
900            remote_connection,
901            |_| true,
902        )
903    }
904
905    pub fn path_is_referenced_by_unarchived_threads_matching(
906        &self,
907        thread_id: Option<ThreadId>,
908        path: &Path,
909        remote_connection: Option<&RemoteConnectionOptions>,
910        matches: impl Fn(&ThreadMetadata) -> bool,
911    ) -> bool {
912        self.entries().any(|thread| {
913            Some(thread.thread_id) != thread_id
914                && !thread.archived
915                && thread.matches_remote_connection(remote_connection)
916                && thread.references_folder_path(path)
917                && matches(thread)
918        })
919    }
920
921    /// Updates a thread's `folder_paths` after an archived worktree has been
922    /// restored to disk. The restored worktree may land at a different path
923    /// than it had before archival, so each `(old_path, new_path)` pair in
924    /// `path_replacements` is applied to the thread's stored folder paths.
925    pub fn update_restored_worktree_paths(
926        &mut self,
927        thread_id: ThreadId,
928        path_replacements: &[(PathBuf, PathBuf)],
929        cx: &mut Context<Self>,
930    ) {
931        if let Some(thread) = self.threads.get(&thread_id).cloned() {
932            let mut paths: Vec<PathBuf> = thread.folder_paths().paths().to_vec();
933            for (old_path, new_path) in path_replacements {
934                if let Some(pos) = paths.iter().position(|p| p == old_path) {
935                    paths[pos] = new_path.clone();
936                }
937            }
938            let new_folder_paths = PathList::new(&paths);
939            self.save_internal(ThreadMetadata {
940                worktree_paths: WorktreePaths::from_path_lists(
941                    thread.main_worktree_paths().clone(),
942                    new_folder_paths.clone(),
943                )
944                .unwrap_or_else(|_| WorktreePaths::from_folder_paths(&new_folder_paths)),
945                ..thread
946            });
947            cx.notify();
948        }
949    }
950
951    pub fn complete_worktree_restore(
952        &mut self,
953        thread_id: ThreadId,
954        path_replacements: &[(PathBuf, PathBuf)],
955        cx: &mut Context<Self>,
956    ) {
957        if let Some(thread) = self.threads.get(&thread_id).cloned() {
958            let mut paths: Vec<PathBuf> = thread.folder_paths().paths().to_vec();
959            for (old_path, new_path) in path_replacements {
960                for path in &mut paths {
961                    if path == old_path {
962                        *path = new_path.clone();
963                    }
964                }
965            }
966            let new_folder_paths = PathList::new(&paths);
967            self.save_internal(ThreadMetadata {
968                worktree_paths: WorktreePaths::from_path_lists(
969                    thread.main_worktree_paths().clone(),
970                    new_folder_paths.clone(),
971                )
972                .unwrap_or_else(|_| WorktreePaths::from_folder_paths(&new_folder_paths)),
973                ..thread
974            });
975            cx.notify();
976        }
977    }
978
979    /// Apply a mutation to the worktree paths of all threads whose current
980    /// `folder_paths` matches `current_folder_paths`, then re-index.
981    /// When `remote_connection` is provided, only threads with a matching
982    /// remote connection are affected.
983    pub fn change_worktree_paths(
984        &mut self,
985        current_folder_paths: &PathList,
986        remote_connection: Option<&RemoteConnectionOptions>,
987        mutate: impl Fn(&mut WorktreePaths),
988        cx: &mut Context<Self>,
989    ) {
990        let thread_ids: Vec<_> = self
991            .threads_by_paths
992            .get(current_folder_paths)
993            .into_iter()
994            .flatten()
995            .filter(|id| {
996                self.threads.get(id).is_some_and(|t| {
997                    !t.archived
998                        && same_remote_connection_identity(
999                            t.remote_connection.as_ref(),
1000                            remote_connection,
1001                        )
1002                })
1003            })
1004            .copied()
1005            .collect();
1006
1007        self.mutate_thread_paths(&thread_ids, mutate, cx);
1008    }
1009
1010    fn mutate_thread_paths(
1011        &mut self,
1012        thread_ids: &[ThreadId],
1013        mutate: impl Fn(&mut WorktreePaths),
1014        cx: &mut Context<Self>,
1015    ) {
1016        if thread_ids.is_empty() {
1017            return;
1018        }
1019
1020        for thread_id in thread_ids {
1021            if let Some(thread) = self.threads.get_mut(thread_id) {
1022                if let Some(ids) = self
1023                    .threads_by_main_paths
1024                    .get_mut(thread.main_worktree_paths())
1025                {
1026                    ids.remove(thread_id);
1027                }
1028                if let Some(ids) = self.threads_by_paths.get_mut(thread.folder_paths()) {
1029                    ids.remove(thread_id);
1030                }
1031
1032                mutate(&mut thread.worktree_paths);
1033
1034                self.threads_by_main_paths
1035                    .entry(thread.main_worktree_paths().clone())
1036                    .or_default()
1037                    .insert(*thread_id);
1038                self.threads_by_paths
1039                    .entry(thread.folder_paths().clone())
1040                    .or_default()
1041                    .insert(*thread_id);
1042
1043                self.pending_thread_ops_tx
1044                    .try_send(DbOperation::Upsert(thread.clone()))
1045                    .log_err();
1046            }
1047        }
1048
1049        cx.notify();
1050    }
1051
1052    pub fn create_archived_worktree(
1053        &self,
1054        worktree_path: String,
1055        main_repo_path: String,
1056        branch_name: Option<String>,
1057        staged_commit_hash: String,
1058        unstaged_commit_hash: String,
1059        original_commit_hash: String,
1060        cx: &App,
1061    ) -> Task<anyhow::Result<i64>> {
1062        let db = self.db.clone();
1063        cx.background_spawn(async move {
1064            db.create_archived_worktree(
1065                worktree_path,
1066                main_repo_path,
1067                branch_name,
1068                staged_commit_hash,
1069                unstaged_commit_hash,
1070                original_commit_hash,
1071            )
1072            .await
1073        })
1074    }
1075
1076    pub fn link_thread_to_archived_worktree(
1077        &self,
1078        thread_id: ThreadId,
1079        archived_worktree_id: i64,
1080        cx: &App,
1081    ) -> Task<anyhow::Result<()>> {
1082        let db = self.db.clone();
1083        cx.background_spawn(async move {
1084            db.link_thread_to_archived_worktree(thread_id, archived_worktree_id)
1085                .await
1086        })
1087    }
1088
1089    pub fn get_archived_worktrees_for_thread(
1090        &self,
1091        thread_id: ThreadId,
1092        cx: &App,
1093    ) -> Task<anyhow::Result<Vec<ArchivedGitWorktree>>> {
1094        let db = self.db.clone();
1095        cx.background_spawn(async move { db.get_archived_worktrees_for_thread(thread_id).await })
1096    }
1097
1098    pub fn delete_archived_worktree(&self, id: i64, cx: &App) -> Task<anyhow::Result<()>> {
1099        let db = self.db.clone();
1100        cx.background_spawn(async move { db.delete_archived_worktree(id).await })
1101    }
1102
1103    pub fn unlink_thread_from_all_archived_worktrees(
1104        &self,
1105        thread_id: ThreadId,
1106        cx: &App,
1107    ) -> Task<anyhow::Result<()>> {
1108        let db = self.db.clone();
1109        cx.background_spawn(async move {
1110            db.unlink_thread_from_all_archived_worktrees(thread_id)
1111                .await
1112        })
1113    }
1114
1115    pub fn is_archived_worktree_referenced(
1116        &self,
1117        archived_worktree_id: i64,
1118        cx: &App,
1119    ) -> Task<anyhow::Result<bool>> {
1120        let db = self.db.clone();
1121        cx.background_spawn(async move {
1122            db.is_archived_worktree_referenced(archived_worktree_id)
1123                .await
1124        })
1125    }
1126
1127    pub fn get_all_archived_branch_names(
1128        &self,
1129        cx: &App,
1130    ) -> Task<anyhow::Result<HashMap<ThreadId, HashMap<PathBuf, String>>>> {
1131        let db = self.db.clone();
1132        cx.background_spawn(async move { db.get_all_archived_branch_names() })
1133    }
1134
1135    fn update_archived(&mut self, thread_id: ThreadId, archived: bool, cx: &mut Context<Self>) {
1136        if let Some(thread) = self.threads.get(&thread_id) {
1137            self.save_internal(ThreadMetadata {
1138                archived,
1139                ..thread.clone()
1140            });
1141            cx.notify();
1142        }
1143    }
1144
1145    pub fn delete(&mut self, thread_id: ThreadId, cx: &mut Context<Self>) {
1146        if let Some(thread) = self.threads.get(&thread_id) {
1147            if let Some(sid) = &thread.session_id {
1148                self.threads_by_session.remove(sid);
1149            }
1150            if let Some(thread_ids) = self.threads_by_paths.get_mut(thread.folder_paths()) {
1151                thread_ids.remove(&thread_id);
1152            }
1153            if !thread.main_worktree_paths().is_empty() {
1154                if let Some(thread_ids) = self
1155                    .threads_by_main_paths
1156                    .get_mut(thread.main_worktree_paths())
1157                {
1158                    thread_ids.remove(&thread_id);
1159                }
1160            }
1161        }
1162        self.threads.remove(&thread_id);
1163        self.pending_thread_ops_tx
1164            .try_send(DbOperation::Delete(thread_id))
1165            .log_err();
1166        crate::draft_prompt_store::delete(thread_id, cx).detach_and_log_err(cx);
1167        cx.notify();
1168    }
1169
1170    pub fn unarchived_draft_ids_matching(
1171        &self,
1172        matches: impl Fn(&ThreadMetadata) -> bool,
1173    ) -> Vec<ThreadId> {
1174        self.entries()
1175            .filter(|thread| thread.is_draft() && !thread.archived && matches(thread))
1176            .map(|thread| thread.thread_id)
1177            .collect()
1178    }
1179
1180    pub fn delete_all(
1181        &mut self,
1182        thread_ids: impl IntoIterator<Item = ThreadId>,
1183        cx: &mut Context<Self>,
1184    ) {
1185        for thread_id in thread_ids {
1186            self.delete(thread_id, cx);
1187        }
1188    }
1189
1190    fn new(db: ThreadMetadataDb, cx: &mut Context<Self>) -> Self {
1191        let weak_store = cx.weak_entity();
1192
1193        cx.observe_new::<crate::ConversationView>(move |_view, _window, cx| {
1194            let view_entity = cx.entity();
1195            let entity_id = view_entity.entity_id();
1196
1197            cx.on_release({
1198                let weak_store = weak_store.clone();
1199                move |_view, cx| {
1200                    weak_store
1201                        .update(cx, |store, _cx| {
1202                            store.conversation_subscriptions.remove(&entity_id);
1203                        })
1204                        .ok();
1205                }
1206            })
1207            .detach();
1208
1209            weak_store
1210                .update(cx, |this, cx| {
1211                    let subscription = cx.subscribe(&view_entity, Self::handle_conversation_event);
1212                    this.conversation_subscriptions
1213                        .insert(entity_id, subscription);
1214                })
1215                .ok();
1216        })
1217        .detach();
1218
1219        let (tx, rx) = async_channel::unbounded();
1220        let _db_operations_task = cx.background_spawn({
1221            let db = db.clone();
1222            async move {
1223                while let Ok(first_update) = rx.recv().await {
1224                    let mut updates = vec![first_update];
1225                    while let Ok(update) = rx.try_recv() {
1226                        updates.push(update);
1227                    }
1228                    let updates = Self::dedup_db_operations(updates);
1229                    for operation in updates {
1230                        match operation {
1231                            DbOperation::Upsert(metadata) => {
1232                                db.save(metadata).await.log_err();
1233                            }
1234                            DbOperation::Delete(thread_id) => {
1235                                db.delete(thread_id).await.log_err();
1236                            }
1237                        }
1238                    }
1239                }
1240            }
1241        });
1242
1243        let mut this = Self {
1244            db,
1245            threads: HashMap::default(),
1246            threads_by_paths: HashMap::default(),
1247            threads_by_main_paths: HashMap::default(),
1248            threads_by_session: HashMap::default(),
1249            reload_task: None,
1250            conversation_subscriptions: HashMap::default(),
1251            pending_thread_ops_tx: tx,
1252            in_flight_archives: HashMap::default(),
1253            _db_operations_task,
1254        };
1255        let _ = this.reload(cx);
1256        this
1257    }
1258
1259    fn dedup_db_operations(operations: Vec<DbOperation>) -> Vec<DbOperation> {
1260        let mut ops = HashMap::default();
1261        for operation in operations.into_iter().rev() {
1262            if ops.contains_key(&operation.id()) {
1263                continue;
1264            }
1265            ops.insert(operation.id(), operation);
1266        }
1267        ops.into_values().collect()
1268    }
1269
1270    fn handle_conversation_event(
1271        &mut self,
1272        conversation_view: Entity<crate::ConversationView>,
1273        _event: &crate::conversation_view::RootThreadUpdated,
1274        cx: &mut Context<Self>,
1275    ) {
1276        let view = conversation_view.read(cx);
1277        let thread_id = view.thread_id;
1278        let Some(thread) = view.root_thread(cx) else {
1279            return;
1280        };
1281
1282        let thread_ref = thread.read(cx);
1283        // Collab-hosted threads don't own their metadata locally.
1284        if thread_ref.project().read(cx).is_via_collab() {
1285            return;
1286        }
1287        let is_draft = thread_ref.is_draft_thread();
1288        let existing_thread = self.entry(thread_id);
1289
1290        // Draft session IDs may change on reload, so let's not save them until they're valid
1291        let session_id = if is_draft {
1292            None
1293        } else {
1294            Some(thread_ref.session_id().clone())
1295        };
1296        let title = thread_ref.title();
1297        let title_override = existing_thread.and_then(|t| t.title_override.clone());
1298
1299        let updated_at = Utc::now();
1300
1301        let created_at = existing_thread
1302            .and_then(|t| t.created_at)
1303            .unwrap_or_else(|| updated_at);
1304
1305        let interacted_at = existing_thread
1306            .map(|t| t.interacted_at)
1307            .unwrap_or(Some(updated_at));
1308
1309        let agent_id = thread_ref.connection().agent_id();
1310
1311        // Preserve project-dependent fields for archived threads.
1312        // The worktree may already have been removed from the
1313        // project as part of the archive flow, so re-evaluating
1314        // these from the current project state would yield
1315        // empty/incorrect results.
1316        let (worktree_paths, remote_connection) =
1317            if let Some(existing) = existing_thread.filter(|t| t.archived) {
1318                (
1319                    existing.worktree_paths.clone(),
1320                    existing.remote_connection.clone(),
1321                )
1322            } else {
1323                let project = thread_ref.project().read(cx);
1324                let worktree_paths = project.worktree_paths(cx);
1325                let remote_connection = project.remote_connection_options(cx);
1326
1327                (worktree_paths, remote_connection)
1328            };
1329
1330        // Threads without a folder path (e.g. started in an empty
1331        // window) are archived by default so they don't get lost,
1332        // because they won't show up in the sidebar. Users can reload
1333        // them from the archive.
1334        let archived = existing_thread
1335            .map(|t| t.archived)
1336            .unwrap_or(worktree_paths.is_empty());
1337
1338        let was_draft = existing_thread.map_or(true, |t| t.is_draft());
1339        if was_draft && !is_draft {
1340            // Draft has been promoted: drop its persisted prompt since the
1341            // promoted thread now owns its prompt state via the native
1342            // agent's thread database.
1343            crate::draft_prompt_store::delete(thread_id, cx).detach_and_log_err(cx);
1344        }
1345
1346        let metadata = ThreadMetadata {
1347            thread_id,
1348            session_id,
1349            agent_id,
1350            title,
1351            title_override,
1352            created_at: Some(created_at),
1353            interacted_at,
1354            updated_at,
1355            worktree_paths,
1356            remote_connection,
1357            archived,
1358        };
1359
1360        self.save(metadata, cx);
1361    }
1362}
1363
1364impl Global for ThreadMetadataStore {}
1365
1366#[derive(Clone, Debug)]
1367pub enum ThreadMetadataStoreEvent {
1368    ThreadArchived(ThreadId),
1369}
1370
1371impl gpui::EventEmitter<ThreadMetadataStoreEvent> for ThreadMetadataStore {}
1372
1373struct ThreadMetadataDb(ThreadSafeConnection);
1374
1375impl Domain for ThreadMetadataDb {
1376    const NAME: &str = stringify!(ThreadMetadataDb);
1377
1378    const MIGRATIONS: &[&str] = &[
1379        sql!(
1380            CREATE TABLE IF NOT EXISTS sidebar_threads(
1381                session_id TEXT PRIMARY KEY,
1382                agent_id TEXT,
1383                title TEXT NOT NULL,
1384                updated_at TEXT NOT NULL,
1385                created_at TEXT,
1386                folder_paths TEXT,
1387                folder_paths_order TEXT
1388            ) STRICT;
1389        ),
1390        sql!(ALTER TABLE sidebar_threads ADD COLUMN archived INTEGER DEFAULT 0),
1391        sql!(ALTER TABLE sidebar_threads ADD COLUMN main_worktree_paths TEXT),
1392        sql!(ALTER TABLE sidebar_threads ADD COLUMN main_worktree_paths_order TEXT),
1393        sql!(
1394            CREATE TABLE IF NOT EXISTS archived_git_worktrees(
1395                id INTEGER PRIMARY KEY,
1396                worktree_path TEXT NOT NULL,
1397                main_repo_path TEXT NOT NULL,
1398                branch_name TEXT,
1399                staged_commit_hash TEXT,
1400                unstaged_commit_hash TEXT,
1401                original_commit_hash TEXT
1402            ) STRICT;
1403
1404            CREATE TABLE IF NOT EXISTS thread_archived_worktrees(
1405                session_id TEXT NOT NULL,
1406                archived_worktree_id INTEGER NOT NULL REFERENCES archived_git_worktrees(id),
1407                PRIMARY KEY (session_id, archived_worktree_id)
1408            ) STRICT;
1409        ),
1410        sql!(ALTER TABLE sidebar_threads ADD COLUMN remote_connection TEXT),
1411        sql!(ALTER TABLE sidebar_threads ADD COLUMN thread_id BLOB),
1412        sql!(
1413            UPDATE sidebar_threads SET thread_id = randomblob(16) WHERE thread_id IS NULL;
1414
1415            CREATE TABLE thread_archived_worktrees_v2(
1416                thread_id BLOB NOT NULL,
1417                archived_worktree_id INTEGER NOT NULL REFERENCES archived_git_worktrees(id),
1418                PRIMARY KEY (thread_id, archived_worktree_id)
1419            ) STRICT;
1420
1421            INSERT INTO thread_archived_worktrees_v2(thread_id, archived_worktree_id)
1422            SELECT s.thread_id, t.archived_worktree_id
1423            FROM thread_archived_worktrees t
1424            JOIN sidebar_threads s ON s.session_id = t.session_id;
1425
1426            DROP TABLE thread_archived_worktrees;
1427            ALTER TABLE thread_archived_worktrees_v2 RENAME TO thread_archived_worktrees;
1428
1429            CREATE TABLE sidebar_threads_v2(
1430                thread_id BLOB PRIMARY KEY,
1431                session_id TEXT,
1432                agent_id TEXT,
1433                title TEXT NOT NULL,
1434                updated_at TEXT NOT NULL,
1435                created_at TEXT,
1436                folder_paths TEXT,
1437                folder_paths_order TEXT,
1438                archived INTEGER DEFAULT 0,
1439                main_worktree_paths TEXT,
1440                main_worktree_paths_order TEXT,
1441                remote_connection TEXT
1442            ) STRICT;
1443
1444            INSERT INTO sidebar_threads_v2(thread_id, session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived, main_worktree_paths, main_worktree_paths_order, remote_connection)
1445            SELECT thread_id, session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived, main_worktree_paths, main_worktree_paths_order, remote_connection
1446            FROM sidebar_threads;
1447
1448            DROP TABLE sidebar_threads;
1449            ALTER TABLE sidebar_threads_v2 RENAME TO sidebar_threads;
1450        ),
1451        sql!(
1452            DELETE FROM thread_archived_worktrees
1453            WHERE thread_id IN (
1454                SELECT thread_id FROM sidebar_threads WHERE session_id IS NULL
1455            );
1456
1457            DELETE FROM sidebar_threads WHERE session_id IS NULL;
1458
1459            DELETE FROM archived_git_worktrees
1460            WHERE id NOT IN (
1461                SELECT archived_worktree_id FROM thread_archived_worktrees
1462            );
1463        ),
1464        sql!(
1465            ALTER TABLE sidebar_threads ADD COLUMN interacted_at TEXT;
1466        ),
1467        sql!(
1468            ALTER TABLE sidebar_threads ADD COLUMN title_override TEXT;
1469        ),
1470    ];
1471}
1472
1473db::static_connection!(ThreadMetadataDb, []);
1474
1475impl ThreadMetadataDb {
1476    #[allow(dead_code)]
1477    pub fn list_ids(&self) -> anyhow::Result<Vec<ThreadId>> {
1478        self.select::<ThreadId>(
1479            "SELECT thread_id FROM sidebar_threads \
1480             ORDER BY updated_at DESC",
1481        )?()
1482    }
1483
1484    const LIST_QUERY: &str = "SELECT thread_id, session_id, agent_id, title, updated_at, \
1485        created_at, interacted_at, folder_paths, folder_paths_order, archived, main_worktree_paths, \
1486        main_worktree_paths_order, remote_connection, title_override \
1487        FROM sidebar_threads \
1488        ORDER BY updated_at DESC";
1489
1490    /// List all sidebar thread metadata, ordered by updated_at descending.
1491    ///
1492    /// Only returns threads that have a `session_id`.
1493    pub fn list(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
1494        self.select::<ThreadMetadata>(Self::LIST_QUERY)?()
1495    }
1496
1497    /// Upsert metadata for a thread.
1498    ///
1499    /// Drafts are persisted with `session_id = None`. They get a real
1500    /// session_id on promotion (when the first message is sent) and
1501    /// then flow through this same upsert path.
1502    pub async fn save(&self, row: ThreadMetadata) -> anyhow::Result<()> {
1503        let session_id = row.session_id.as_ref().map(|s| s.0.clone());
1504        let agent_id = if row.agent_id.as_ref() == OMEGA_AGENT_ID.as_ref() {
1505            None
1506        } else {
1507            Some(row.agent_id.to_string())
1508        };
1509        let title = row
1510            .title
1511            .as_ref()
1512            .map(|t| t.to_string())
1513            .unwrap_or_default();
1514        let updated_at = row.updated_at.to_rfc3339();
1515        let created_at = row.created_at.map(|dt| dt.to_rfc3339());
1516        let interacted_at = row.interacted_at.map(|dt| dt.to_rfc3339());
1517        let serialized = row.folder_paths().serialize();
1518        let (folder_paths, folder_paths_order) = if row.folder_paths().is_empty() {
1519            (None, None)
1520        } else {
1521            (Some(serialized.paths), Some(serialized.order))
1522        };
1523        let main_serialized = row.main_worktree_paths().serialize();
1524        let (main_worktree_paths, main_worktree_paths_order) =
1525            if row.main_worktree_paths().is_empty() {
1526                (None, None)
1527            } else {
1528                (Some(main_serialized.paths), Some(main_serialized.order))
1529            };
1530        let remote_connection = row
1531            .remote_connection
1532            .as_ref()
1533            .map(serde_json::to_string)
1534            .transpose()
1535            .context("serialize thread metadata remote connection")?;
1536        let title_override = row.title_override.as_ref().map(|t| t.to_string());
1537        let thread_id = row.thread_id;
1538        let archived = row.archived;
1539
1540        self.write(move |conn| {
1541            let sql = "INSERT INTO sidebar_threads(thread_id, session_id, agent_id, title, updated_at, created_at, interacted_at, folder_paths, folder_paths_order, archived, main_worktree_paths, main_worktree_paths_order, remote_connection, title_override) \
1542                       VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) \
1543                       ON CONFLICT(thread_id) DO UPDATE SET \
1544                           session_id = excluded.session_id, \
1545                           agent_id = excluded.agent_id, \
1546                           title = excluded.title, \
1547                           updated_at = excluded.updated_at, \
1548                           created_at = excluded.created_at, \
1549                           interacted_at = excluded.interacted_at, \
1550                           folder_paths = excluded.folder_paths, \
1551                           folder_paths_order = excluded.folder_paths_order, \
1552                           archived = excluded.archived, \
1553                           main_worktree_paths = excluded.main_worktree_paths, \
1554                           main_worktree_paths_order = excluded.main_worktree_paths_order, \
1555                           remote_connection = excluded.remote_connection, \
1556                           title_override = excluded.title_override";
1557            let mut stmt = Statement::prepare(conn, sql)?;
1558            let mut i = stmt.bind(&thread_id, 1)?;
1559            i = stmt.bind(&session_id, i)?;
1560            i = stmt.bind(&agent_id, i)?;
1561            i = stmt.bind(&title, i)?;
1562            i = stmt.bind(&updated_at, i)?;
1563            i = stmt.bind(&created_at, i)?;
1564            i = stmt.bind(&interacted_at, i)?;
1565            i = stmt.bind(&folder_paths, i)?;
1566            i = stmt.bind(&folder_paths_order, i)?;
1567            i = stmt.bind(&archived, i)?;
1568            i = stmt.bind(&main_worktree_paths, i)?;
1569            i = stmt.bind(&main_worktree_paths_order, i)?;
1570            i = stmt.bind(&remote_connection, i)?;
1571            stmt.bind(&title_override, i)?;
1572            stmt.exec()
1573        })
1574        .await
1575    }
1576
1577    /// Delete metadata for a single thread.
1578    pub async fn delete(&self, thread_id: ThreadId) -> anyhow::Result<()> {
1579        self.write(move |conn| {
1580            let mut stmt =
1581                Statement::prepare(conn, "DELETE FROM sidebar_threads WHERE thread_id = ?")?;
1582            stmt.bind(&thread_id, 1)?;
1583            stmt.exec()
1584        })
1585        .await
1586    }
1587
1588    pub async fn create_archived_worktree(
1589        &self,
1590        worktree_path: String,
1591        main_repo_path: String,
1592        branch_name: Option<String>,
1593        staged_commit_hash: String,
1594        unstaged_commit_hash: String,
1595        original_commit_hash: String,
1596    ) -> anyhow::Result<i64> {
1597        self.write(move |conn| {
1598            let mut stmt = Statement::prepare(
1599                conn,
1600                "INSERT INTO archived_git_worktrees(worktree_path, main_repo_path, branch_name, staged_commit_hash, unstaged_commit_hash, original_commit_hash) \
1601                 VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
1602                 RETURNING id",
1603            )?;
1604            let mut i = stmt.bind(&worktree_path, 1)?;
1605            i = stmt.bind(&main_repo_path, i)?;
1606            i = stmt.bind(&branch_name, i)?;
1607            i = stmt.bind(&staged_commit_hash, i)?;
1608            i = stmt.bind(&unstaged_commit_hash, i)?;
1609            stmt.bind(&original_commit_hash, i)?;
1610            stmt.maybe_row::<i64>()?.context("expected RETURNING id")
1611        })
1612        .await
1613    }
1614
1615    pub async fn link_thread_to_archived_worktree(
1616        &self,
1617        thread_id: ThreadId,
1618        archived_worktree_id: i64,
1619    ) -> anyhow::Result<()> {
1620        self.write(move |conn| {
1621            let mut stmt = Statement::prepare(
1622                conn,
1623                "INSERT INTO thread_archived_worktrees(thread_id, archived_worktree_id) \
1624                 VALUES (?1, ?2)",
1625            )?;
1626            let i = stmt.bind(&thread_id, 1)?;
1627            stmt.bind(&archived_worktree_id, i)?;
1628            stmt.exec()
1629        })
1630        .await
1631    }
1632
1633    pub async fn get_archived_worktrees_for_thread(
1634        &self,
1635        thread_id: ThreadId,
1636    ) -> anyhow::Result<Vec<ArchivedGitWorktree>> {
1637        self.select_bound::<ThreadId, ArchivedGitWorktree>(
1638            "SELECT a.id, a.worktree_path, a.main_repo_path, a.branch_name, a.staged_commit_hash, a.unstaged_commit_hash, a.original_commit_hash \
1639             FROM archived_git_worktrees a \
1640             JOIN thread_archived_worktrees t ON a.id = t.archived_worktree_id \
1641             WHERE t.thread_id = ?1",
1642        )?(thread_id)
1643    }
1644
1645    pub async fn delete_archived_worktree(&self, id: i64) -> anyhow::Result<()> {
1646        self.write(move |conn| {
1647            let mut stmt = Statement::prepare(
1648                conn,
1649                "DELETE FROM thread_archived_worktrees WHERE archived_worktree_id = ?",
1650            )?;
1651            stmt.bind(&id, 1)?;
1652            stmt.exec()?;
1653
1654            let mut stmt =
1655                Statement::prepare(conn, "DELETE FROM archived_git_worktrees WHERE id = ?")?;
1656            stmt.bind(&id, 1)?;
1657            stmt.exec()
1658        })
1659        .await
1660    }
1661
1662    pub async fn unlink_thread_from_all_archived_worktrees(
1663        &self,
1664        thread_id: ThreadId,
1665    ) -> anyhow::Result<()> {
1666        self.write(move |conn| {
1667            let mut stmt = Statement::prepare(
1668                conn,
1669                "DELETE FROM thread_archived_worktrees WHERE thread_id = ?",
1670            )?;
1671            stmt.bind(&thread_id, 1)?;
1672            stmt.exec()
1673        })
1674        .await
1675    }
1676
1677    pub async fn is_archived_worktree_referenced(
1678        &self,
1679        archived_worktree_id: i64,
1680    ) -> anyhow::Result<bool> {
1681        self.select_row_bound::<i64, i64>(
1682            "SELECT COUNT(*) FROM thread_archived_worktrees WHERE archived_worktree_id = ?1",
1683        )?(archived_worktree_id)
1684        .map(|count| count.unwrap_or(0) > 0)
1685    }
1686
1687    pub fn get_all_archived_branch_names(
1688        &self,
1689    ) -> anyhow::Result<HashMap<ThreadId, HashMap<PathBuf, String>>> {
1690        let rows = self.select::<(ThreadId, String, String)>(
1691            "SELECT t.thread_id, a.worktree_path, a.branch_name \
1692             FROM thread_archived_worktrees t \
1693             JOIN archived_git_worktrees a ON a.id = t.archived_worktree_id \
1694             WHERE a.branch_name IS NOT NULL \
1695             ORDER BY a.id ASC",
1696        )?()?;
1697
1698        let mut result: HashMap<ThreadId, HashMap<PathBuf, String>> = HashMap::default();
1699        for (thread_id, worktree_path, branch_name) in rows {
1700            result
1701                .entry(thread_id)
1702                .or_default()
1703                .insert(PathBuf::from(worktree_path), branch_name);
1704        }
1705        Ok(result)
1706    }
1707}
1708
1709impl Column for ThreadMetadata {
1710    fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
1711        let (thread_id_uuid, next): (uuid::Uuid, i32) = Column::column(statement, start_index)?;
1712        let (id, next): (Option<Arc<str>>, i32) = Column::column(statement, next)?;
1713        let (agent_id, next): (Option<String>, i32) = Column::column(statement, next)?;
1714        let (title, next): (String, i32) = Column::column(statement, next)?;
1715        let (updated_at_str, next): (String, i32) = Column::column(statement, next)?;
1716        let (created_at_str, next): (Option<String>, i32) = Column::column(statement, next)?;
1717        let (interacted_at_str, next): (Option<String>, i32) = Column::column(statement, next)?;
1718        let (folder_paths_str, next): (Option<String>, i32) = Column::column(statement, next)?;
1719        let (folder_paths_order_str, next): (Option<String>, i32) =
1720            Column::column(statement, next)?;
1721        let (archived, next): (bool, i32) = Column::column(statement, next)?;
1722        let (main_worktree_paths_str, next): (Option<String>, i32) =
1723            Column::column(statement, next)?;
1724        let (main_worktree_paths_order_str, next): (Option<String>, i32) =
1725            Column::column(statement, next)?;
1726        let (remote_connection_json, next): (Option<String>, i32) =
1727            Column::column(statement, next)?;
1728        let (title_override, next): (Option<String>, i32) = Column::column(statement, next)?;
1729
1730        let agent_id = agent_id
1731            .map(|id| AgentId::new(id))
1732            .unwrap_or(OMEGA_AGENT_ID.clone());
1733
1734        let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc);
1735        let created_at = created_at_str
1736            .as_deref()
1737            .map(DateTime::parse_from_rfc3339)
1738            .transpose()?
1739            .map(|dt| dt.with_timezone(&Utc));
1740
1741        let interacted_at = interacted_at_str
1742            .as_deref()
1743            .map(DateTime::parse_from_rfc3339)
1744            .transpose()?
1745            .map(|dt| dt.with_timezone(&Utc));
1746
1747        let folder_paths = folder_paths_str
1748            .map(|paths| {
1749                PathList::deserialize(&util::path_list::SerializedPathList {
1750                    paths,
1751                    order: folder_paths_order_str.unwrap_or_default(),
1752                })
1753            })
1754            .unwrap_or_default();
1755
1756        let main_worktree_paths = main_worktree_paths_str
1757            .map(|paths| {
1758                PathList::deserialize(&util::path_list::SerializedPathList {
1759                    paths,
1760                    order: main_worktree_paths_order_str.unwrap_or_default(),
1761                })
1762            })
1763            .unwrap_or_default();
1764
1765        let remote_connection = remote_connection_json
1766            .as_deref()
1767            .map(serde_json::from_str::<RemoteConnectionOptions>)
1768            .transpose()
1769            .context("deserialize thread metadata remote connection")?;
1770
1771        let worktree_paths = WorktreePaths::from_path_lists(main_worktree_paths, folder_paths)
1772            .unwrap_or_else(|_| WorktreePaths::default());
1773
1774        let thread_id = ThreadId(thread_id_uuid);
1775
1776        Ok((
1777            ThreadMetadata {
1778                thread_id,
1779                session_id: id.map(acp::SessionId::new),
1780                agent_id,
1781                title: if title.is_empty() || title == DEFAULT_THREAD_TITLE {
1782                    None
1783                } else {
1784                    Some(title.into())
1785                },
1786                title_override: title_override
1787                    .filter(|t| !t.is_empty())
1788                    .map(SharedString::from),
1789                updated_at,
1790                created_at,
1791                interacted_at,
1792                worktree_paths,
1793                remote_connection,
1794                archived,
1795            },
1796            next,
1797        ))
1798    }
1799}
1800
1801impl Column for ArchivedGitWorktree {
1802    fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
1803        let (id, next): (i64, i32) = Column::column(statement, start_index)?;
1804        let (worktree_path_str, next): (String, i32) = Column::column(statement, next)?;
1805        let (main_repo_path_str, next): (String, i32) = Column::column(statement, next)?;
1806        let (branch_name, next): (Option<String>, i32) = Column::column(statement, next)?;
1807        let (staged_commit_hash, next): (String, i32) = Column::column(statement, next)?;
1808        let (unstaged_commit_hash, next): (String, i32) = Column::column(statement, next)?;
1809        let (original_commit_hash, next): (String, i32) = Column::column(statement, next)?;
1810
1811        Ok((
1812            ArchivedGitWorktree {
1813                id,
1814                worktree_path: PathBuf::from(worktree_path_str),
1815                main_repo_path: PathBuf::from(main_repo_path_str),
1816                branch_name,
1817                staged_commit_hash,
1818                unstaged_commit_hash,
1819                original_commit_hash,
1820            },
1821            next,
1822        ))
1823    }
1824}
1825
1826#[cfg(test)]
1827mod tests {
1828    use super::*;
1829    use acp_thread::StubAgentConnection;
1830    use action_log::ActionLog;
1831    use agent::DbThread;
1832    use agent_client_protocol::schema::v1 as acp;
1833    use gpui::{TestAppContext, VisualTestContext};
1834    use project::FakeFs;
1835    use project::Project;
1836    use remote::WslConnectionOptions;
1837    use std::path::Path;
1838    use std::rc::Rc;
1839    use workspace::MultiWorkspace;
1840
1841    fn make_db_thread(title: &str, updated_at: DateTime<Utc>) -> DbThread {
1842        DbThread {
1843            title: title.to_string().into(),
1844            messages: Vec::new(),
1845            updated_at,
1846            detailed_summary: None,
1847            initial_project_snapshot: None,
1848            cumulative_token_usage: Default::default(),
1849            request_token_usage: Default::default(),
1850            model: None,
1851            profile: None,
1852            subagent_context: None,
1853            speed: None,
1854            thinking_enabled: false,
1855            thinking_effort: None,
1856            draft_prompt: None,
1857            ui_scroll_position: None,
1858            sandboxed_terminal_temp_dir: None,
1859            sandbox_grants: Default::default(),
1860        }
1861    }
1862
1863    fn make_metadata(
1864        session_id: &str,
1865        title: &str,
1866        updated_at: DateTime<Utc>,
1867        folder_paths: PathList,
1868    ) -> ThreadMetadata {
1869        ThreadMetadata {
1870            thread_id: ThreadId::new(),
1871            archived: false,
1872            session_id: Some(acp::SessionId::new(session_id)),
1873            agent_id: agent::OMEGA_AGENT_ID.clone(),
1874            title: if title.is_empty() {
1875                None
1876            } else {
1877                Some(title.to_string().into())
1878            },
1879            title_override: None,
1880            updated_at,
1881            created_at: Some(updated_at),
1882            interacted_at: None,
1883            worktree_paths: WorktreePaths::from_folder_paths(&folder_paths),
1884            remote_connection: None,
1885        }
1886    }
1887
1888    fn init_test(cx: &mut TestAppContext) {
1889        let fs = FakeFs::new(cx.executor());
1890        cx.update(|cx| {
1891            let settings_store = settings::SettingsStore::test(cx);
1892            cx.set_global(settings_store);
1893            theme_settings::init(theme::LoadThemes::JustBase, cx);
1894            editor::init(cx);
1895            release_channel::init("0.0.0".parse().unwrap(), cx);
1896            prompt_store::init(cx);
1897            <dyn Fs>::set_global(fs, cx);
1898            ThreadMetadataStore::init_global(cx);
1899            ThreadStore::init_global(cx);
1900            language_model::LanguageModelRegistry::test(cx);
1901        });
1902        cx.run_until_parked();
1903    }
1904
1905    fn setup_panel_with_project(
1906        project: Entity<Project>,
1907        cx: &mut TestAppContext,
1908    ) -> (Entity<crate::AgentPanel>, VisualTestContext) {
1909        let multi_workspace =
1910            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1911        let workspace_entity = multi_workspace
1912            .read_with(cx, |mw, _cx| mw.workspace().clone())
1913            .unwrap();
1914        let mut vcx = VisualTestContext::from_window(multi_workspace.into(), cx);
1915        let panel = workspace_entity.update_in(&mut vcx, |workspace, window, cx| {
1916            cx.new(|cx| crate::AgentPanel::new(workspace, window, cx))
1917        });
1918        (panel, vcx)
1919    }
1920
1921    fn clear_thread_metadata_remote_connection_backfill(cx: &mut TestAppContext) {
1922        let kvp = cx.update(|cx| KeyValueStore::global(cx));
1923        gpui::block_on(kvp.delete_kvp("thread-metadata-remote-connection-backfill".to_string()))
1924            .unwrap();
1925    }
1926
1927    fn run_store_migrations(cx: &mut TestAppContext) {
1928        clear_thread_metadata_remote_connection_backfill(cx);
1929        cx.update(|cx| {
1930            let migration_task = migrate_thread_metadata(cx);
1931            migrate_thread_remote_connections(cx, migration_task);
1932        });
1933        cx.run_until_parked();
1934    }
1935
1936    #[test]
1937    fn test_thread_metadata_title_prefers_override() {
1938        let mut metadata = make_metadata(
1939            "session-1",
1940            "Agent Generated Title",
1941            Utc::now(),
1942            PathList::default(),
1943        );
1944        metadata.title_override = Some("User Title".into());
1945
1946        assert_eq!(metadata.title().as_deref(), Some("User Title"));
1947        assert_eq!(metadata.display_title().as_ref(), "User Title");
1948
1949        metadata.title_override = None;
1950        assert_eq!(metadata.title().as_deref(), Some("Agent Generated Title"));
1951        assert_eq!(metadata.display_title().as_ref(), "Agent Generated Title");
1952    }
1953
1954    #[gpui::test]
1955    async fn test_database_round_trips_title_override(_cx: &mut TestAppContext) {
1956        let now = Utc::now();
1957        let mut metadata = make_metadata(
1958            "session-1",
1959            "Agent Generated Title",
1960            now,
1961            PathList::new(&[Path::new("/project-a")]),
1962        );
1963        metadata.title_override = Some("User Title".into());
1964
1965        let thread = std::thread::current();
1966        let test_name = thread.name().unwrap_or("unknown_test");
1967        let db_name = format!("THREAD_METADATA_DB_{}", test_name);
1968        let db = ThreadMetadataDb(gpui::block_on(db::open_test_db::<ThreadMetadataDb>(
1969            &db_name,
1970        )));
1971
1972        db.save(metadata).await.unwrap();
1973
1974        let rows = db.list().unwrap();
1975        assert_eq!(rows.len(), 1);
1976        assert_eq!(rows[0].title.as_deref(), Some("Agent Generated Title"));
1977        assert_eq!(rows[0].title_override.as_deref(), Some("User Title"));
1978        assert_eq!(rows[0].title().as_deref(), Some("User Title"));
1979    }
1980
1981    #[gpui::test]
1982    async fn test_store_set_title_override_updates_cached_metadata(cx: &mut TestAppContext) {
1983        init_test(cx);
1984
1985        let metadata = make_metadata(
1986            "session-1",
1987            "Agent Generated Title",
1988            Utc::now(),
1989            PathList::default(),
1990        );
1991        let thread_id = metadata.thread_id;
1992
1993        cx.update(|cx| {
1994            let store = ThreadMetadataStore::global(cx);
1995            store.update(cx, |store, cx| {
1996                store.save(metadata, cx);
1997                store.set_title_override(thread_id, "User Title".into(), cx);
1998            });
1999        });
2000
2001        cx.run_until_parked();
2002
2003        cx.update(|cx| {
2004            let store = ThreadMetadataStore::global(cx);
2005            let store = store.read(cx);
2006            let metadata = store.entry(thread_id).expect("metadata should be cached");
2007            assert_eq!(metadata.title.as_deref(), Some("Agent Generated Title"));
2008            assert_eq!(metadata.title_override.as_deref(), Some("User Title"));
2009            assert_eq!(metadata.display_title().as_ref(), "User Title");
2010        });
2011    }
2012
2013    #[gpui::test]
2014    async fn test_store_set_generated_title_clears_title_override(cx: &mut TestAppContext) {
2015        init_test(cx);
2016
2017        let mut metadata = make_metadata(
2018            "session-1",
2019            "Old Generated Title",
2020            Utc::now(),
2021            PathList::default(),
2022        );
2023        metadata.title_override = Some("User Title".into());
2024        let thread_id = metadata.thread_id;
2025
2026        cx.update(|cx| {
2027            let store = ThreadMetadataStore::global(cx);
2028            store.update(cx, |store, cx| {
2029                store.save(metadata, cx);
2030                store.set_generated_title(thread_id, "New Generated Title".into(), cx);
2031            });
2032        });
2033
2034        cx.run_until_parked();
2035
2036        cx.update(|cx| {
2037            let store = ThreadMetadataStore::global(cx);
2038            let store = store.read(cx);
2039            let metadata = store.entry(thread_id).expect("metadata should be cached");
2040            assert_eq!(metadata.title.as_deref(), Some("New Generated Title"));
2041            assert_eq!(metadata.title_override, None);
2042            assert_eq!(metadata.display_title().as_ref(), "New Generated Title");
2043        });
2044    }
2045
2046    #[gpui::test]
2047    async fn test_store_initializes_cache_from_database(cx: &mut TestAppContext) {
2048        let first_paths = PathList::new(&[Path::new("/project-a")]);
2049        let second_paths = PathList::new(&[Path::new("/project-b")]);
2050        let now = Utc::now();
2051        let older = now - chrono::Duration::seconds(1);
2052
2053        let thread = std::thread::current();
2054        let test_name = thread.name().unwrap_or("unknown_test");
2055        let db_name = format!("THREAD_METADATA_DB_{}", test_name);
2056        let db = ThreadMetadataDb(gpui::block_on(db::open_test_db::<ThreadMetadataDb>(
2057            &db_name,
2058        )));
2059
2060        db.save(make_metadata(
2061            "session-1",
2062            "First Thread",
2063            now,
2064            first_paths.clone(),
2065        ))
2066        .await
2067        .unwrap();
2068        db.save(make_metadata(
2069            "session-2",
2070            "Second Thread",
2071            older,
2072            second_paths.clone(),
2073        ))
2074        .await
2075        .unwrap();
2076
2077        cx.update(|cx| {
2078            let settings_store = settings::SettingsStore::test(cx);
2079            cx.set_global(settings_store);
2080            ThreadMetadataStore::init_global(cx);
2081        });
2082
2083        cx.run_until_parked();
2084
2085        cx.update(|cx| {
2086            let store = ThreadMetadataStore::global(cx);
2087            let store = store.read(cx);
2088
2089            assert_eq!(store.entry_ids().count(), 2);
2090            assert!(
2091                store
2092                    .entry_by_session(&acp::SessionId::new("session-1"))
2093                    .is_some()
2094            );
2095            assert!(
2096                store
2097                    .entry_by_session(&acp::SessionId::new("session-2"))
2098                    .is_some()
2099            );
2100
2101            let first_path_entries: Vec<_> = store
2102                .entries_for_path(&first_paths, None)
2103                .filter_map(|entry| entry.session_id.as_ref().map(|s| s.0.to_string()))
2104                .collect();
2105            assert_eq!(first_path_entries, vec!["session-1"]);
2106
2107            let second_path_entries: Vec<_> = store
2108                .entries_for_path(&second_paths, None)
2109                .filter_map(|entry| entry.session_id.as_ref().map(|s| s.0.to_string()))
2110                .collect();
2111            assert_eq!(second_path_entries, vec!["session-2"]);
2112        });
2113    }
2114
2115    #[gpui::test]
2116    async fn test_store_cache_updates_after_save_and_delete(cx: &mut TestAppContext) {
2117        init_test(cx);
2118
2119        let first_paths = PathList::new(&[Path::new("/project-a")]);
2120        let second_paths = PathList::new(&[Path::new("/project-b")]);
2121        let initial_time = Utc::now();
2122        let updated_time = initial_time + chrono::Duration::seconds(1);
2123
2124        let initial_metadata = make_metadata(
2125            "session-1",
2126            "First Thread",
2127            initial_time,
2128            first_paths.clone(),
2129        );
2130        let session1_thread_id = initial_metadata.thread_id;
2131
2132        let second_metadata = make_metadata(
2133            "session-2",
2134            "Second Thread",
2135            initial_time,
2136            second_paths.clone(),
2137        );
2138        let session2_thread_id = second_metadata.thread_id;
2139
2140        cx.update(|cx| {
2141            let store = ThreadMetadataStore::global(cx);
2142            store.update(cx, |store, cx| {
2143                store.save(initial_metadata, cx);
2144                store.save(second_metadata, cx);
2145            });
2146        });
2147
2148        cx.run_until_parked();
2149
2150        cx.update(|cx| {
2151            let store = ThreadMetadataStore::global(cx);
2152            let store = store.read(cx);
2153
2154            let first_path_entries: Vec<_> = store
2155                .entries_for_path(&first_paths, None)
2156                .filter_map(|entry| entry.session_id.as_ref().map(|s| s.0.to_string()))
2157                .collect();
2158            assert_eq!(first_path_entries, vec!["session-1"]);
2159
2160            let second_path_entries: Vec<_> = store
2161                .entries_for_path(&second_paths, None)
2162                .filter_map(|entry| entry.session_id.as_ref().map(|s| s.0.to_string()))
2163                .collect();
2164            assert_eq!(second_path_entries, vec!["session-2"]);
2165        });
2166
2167        let moved_metadata = ThreadMetadata {
2168            thread_id: session1_thread_id,
2169            session_id: Some(acp::SessionId::new("session-1")),
2170            agent_id: agent::OMEGA_AGENT_ID.clone(),
2171            title: Some("First Thread".into()),
2172            title_override: None,
2173            updated_at: updated_time,
2174            created_at: Some(updated_time),
2175            interacted_at: None,
2176            worktree_paths: WorktreePaths::from_folder_paths(&second_paths),
2177            remote_connection: None,
2178            archived: false,
2179        };
2180
2181        cx.update(|cx| {
2182            let store = ThreadMetadataStore::global(cx);
2183            store.update(cx, |store, cx| {
2184                store.save(moved_metadata, cx);
2185            });
2186        });
2187
2188        cx.run_until_parked();
2189
2190        cx.update(|cx| {
2191            let store = ThreadMetadataStore::global(cx);
2192            let store = store.read(cx);
2193
2194            assert_eq!(store.entry_ids().count(), 2);
2195            assert!(
2196                store
2197                    .entry_by_session(&acp::SessionId::new("session-1"))
2198                    .is_some()
2199            );
2200            assert!(
2201                store
2202                    .entry_by_session(&acp::SessionId::new("session-2"))
2203                    .is_some()
2204            );
2205
2206            let first_path_entries: Vec<_> = store
2207                .entries_for_path(&first_paths, None)
2208                .filter_map(|entry| entry.session_id.as_ref().map(|s| s.0.to_string()))
2209                .collect();
2210            assert!(first_path_entries.is_empty());
2211
2212            let second_path_entries: Vec<_> = store
2213                .entries_for_path(&second_paths, None)
2214                .filter_map(|entry| entry.session_id.as_ref().map(|s| s.0.to_string()))
2215                .collect();
2216            assert_eq!(second_path_entries.len(), 2);
2217            assert!(second_path_entries.contains(&"session-1".to_string()));
2218            assert!(second_path_entries.contains(&"session-2".to_string()));
2219        });
2220
2221        cx.update(|cx| {
2222            let store = ThreadMetadataStore::global(cx);
2223            store.update(cx, |store, cx| {
2224                store.delete(session2_thread_id, cx);
2225            });
2226        });
2227
2228        cx.run_until_parked();
2229
2230        cx.update(|cx| {
2231            let store = ThreadMetadataStore::global(cx);
2232            let store = store.read(cx);
2233
2234            assert_eq!(store.entry_ids().count(), 1);
2235
2236            let second_path_entries: Vec<_> = store
2237                .entries_for_path(&second_paths, None)
2238                .filter_map(|entry| entry.session_id.as_ref().map(|s| s.0.to_string()))
2239                .collect();
2240            assert_eq!(second_path_entries, vec!["session-1"]);
2241        });
2242    }
2243
2244    #[gpui::test]
2245    async fn test_migrate_thread_metadata_migrates_only_missing_threads(cx: &mut TestAppContext) {
2246        init_test(cx);
2247
2248        let project_a_paths = PathList::new(&[Path::new("/project-a")]);
2249        let project_b_paths = PathList::new(&[Path::new("/project-b")]);
2250        let now = Utc::now();
2251
2252        let existing_metadata = ThreadMetadata {
2253            thread_id: ThreadId::new(),
2254            session_id: Some(acp::SessionId::new("a-session-0")),
2255            agent_id: agent::OMEGA_AGENT_ID.clone(),
2256            title: Some("Existing Metadata".into()),
2257            title_override: None,
2258            updated_at: now - chrono::Duration::seconds(10),
2259            created_at: Some(now - chrono::Duration::seconds(10)),
2260            interacted_at: None,
2261            worktree_paths: WorktreePaths::from_folder_paths(&project_a_paths),
2262            remote_connection: None,
2263            archived: false,
2264        };
2265
2266        cx.update(|cx| {
2267            let store = ThreadMetadataStore::global(cx);
2268            store.update(cx, |store, cx| {
2269                store.save(existing_metadata, cx);
2270            });
2271        });
2272        cx.run_until_parked();
2273
2274        let threads_to_save = vec![
2275            (
2276                "a-session-0",
2277                "Thread A0 From Native Store",
2278                project_a_paths.clone(),
2279                now,
2280            ),
2281            (
2282                "a-session-1",
2283                "Thread A1",
2284                project_a_paths.clone(),
2285                now + chrono::Duration::seconds(1),
2286            ),
2287            (
2288                "b-session-0",
2289                "Thread B0",
2290                project_b_paths.clone(),
2291                now + chrono::Duration::seconds(2),
2292            ),
2293            (
2294                "projectless",
2295                "Projectless",
2296                PathList::default(),
2297                now + chrono::Duration::seconds(3),
2298            ),
2299        ];
2300
2301        for (session_id, title, paths, updated_at) in &threads_to_save {
2302            let save_task = cx.update(|cx| {
2303                let thread_store = ThreadStore::global(cx);
2304                let session_id = session_id.to_string();
2305                let title = title.to_string();
2306                let paths = paths.clone();
2307                thread_store.update(cx, |store, cx| {
2308                    store.save_thread(
2309                        acp::SessionId::new(session_id),
2310                        make_db_thread(&title, *updated_at),
2311                        paths,
2312                        cx,
2313                    )
2314                })
2315            });
2316            save_task.await.unwrap();
2317            cx.run_until_parked();
2318        }
2319
2320        run_store_migrations(cx);
2321
2322        let list = cx.update(|cx| {
2323            let store = ThreadMetadataStore::global(cx);
2324            store.read(cx).entries().cloned().collect::<Vec<_>>()
2325        });
2326
2327        assert_eq!(list.len(), 4);
2328        assert!(
2329            list.iter()
2330                .all(|metadata| metadata.agent_id.as_ref() == agent::OMEGA_AGENT_ID.as_ref())
2331        );
2332
2333        let existing_metadata = list
2334            .iter()
2335            .find(|metadata| {
2336                metadata
2337                    .session_id
2338                    .as_ref()
2339                    .is_some_and(|s| s.0.as_ref() == "a-session-0")
2340            })
2341            .unwrap();
2342        assert_eq!(existing_metadata.display_title(), "Existing Metadata");
2343        assert!(!existing_metadata.archived);
2344
2345        let migrated_session_ids: Vec<_> = list
2346            .iter()
2347            .filter_map(|metadata| metadata.session_id.as_ref().map(|s| s.0.to_string()))
2348            .collect();
2349        assert!(migrated_session_ids.iter().any(|s| s == "a-session-1"));
2350        assert!(migrated_session_ids.iter().any(|s| s == "b-session-0"));
2351        assert!(migrated_session_ids.iter().any(|s| s == "projectless"));
2352
2353        // The per-batch top-5 rescue applies: each migrated thread that has
2354        // a project becomes the most-recent-in-its-project within this batch
2355        // and is unarchived. Only the projectless thread stays archived,
2356        // because the rescue only applies to threads with a folder path.
2357        let migrated_by_session: HashMap<String, &ThreadMetadata> = list
2358            .iter()
2359            .filter_map(|metadata| {
2360                let session_id = metadata.session_id.as_ref()?.0.to_string();
2361                (session_id != "a-session-0").then_some((session_id, metadata))
2362            })
2363            .collect();
2364        assert!(!migrated_by_session["a-session-1"].archived);
2365        assert!(!migrated_by_session["b-session-0"].archived);
2366        assert!(migrated_by_session["projectless"].archived);
2367    }
2368
2369    #[gpui::test]
2370    async fn test_migrate_thread_metadata_noops_when_all_threads_already_exist(
2371        cx: &mut TestAppContext,
2372    ) {
2373        init_test(cx);
2374
2375        let project_paths = PathList::new(&[Path::new("/project-a")]);
2376        let existing_updated_at = Utc::now();
2377
2378        let existing_metadata = ThreadMetadata {
2379            thread_id: ThreadId::new(),
2380            session_id: Some(acp::SessionId::new("existing-session")),
2381            agent_id: agent::OMEGA_AGENT_ID.clone(),
2382            title: Some("Existing Metadata".into()),
2383            title_override: None,
2384            updated_at: existing_updated_at,
2385            created_at: Some(existing_updated_at),
2386            interacted_at: None,
2387            worktree_paths: WorktreePaths::from_folder_paths(&project_paths),
2388            remote_connection: None,
2389            archived: false,
2390        };
2391
2392        cx.update(|cx| {
2393            let store = ThreadMetadataStore::global(cx);
2394            store.update(cx, |store, cx| {
2395                store.save(existing_metadata, cx);
2396            });
2397        });
2398        cx.run_until_parked();
2399
2400        let save_task = cx.update(|cx| {
2401            let thread_store = ThreadStore::global(cx);
2402            thread_store.update(cx, |store, cx| {
2403                store.save_thread(
2404                    acp::SessionId::new("existing-session"),
2405                    make_db_thread(
2406                        "Updated Native Thread Title",
2407                        existing_updated_at + chrono::Duration::seconds(1),
2408                    ),
2409                    project_paths.clone(),
2410                    cx,
2411                )
2412            })
2413        });
2414        save_task.await.unwrap();
2415        cx.run_until_parked();
2416
2417        run_store_migrations(cx);
2418
2419        let list = cx.update(|cx| {
2420            let store = ThreadMetadataStore::global(cx);
2421            store.read(cx).entries().cloned().collect::<Vec<_>>()
2422        });
2423
2424        assert_eq!(list.len(), 1);
2425        assert_eq!(
2426            list[0].session_id.as_ref().unwrap().0.as_ref(),
2427            "existing-session"
2428        );
2429    }
2430
2431    #[gpui::test]
2432    async fn test_migrate_thread_remote_connections_backfills_from_workspace_db(
2433        cx: &mut TestAppContext,
2434    ) {
2435        init_test(cx);
2436
2437        let folder_paths = PathList::new(&[Path::new("/remote-project")]);
2438        let updated_at = Utc::now();
2439        let metadata = make_metadata(
2440            "remote-session",
2441            "Remote Thread",
2442            updated_at,
2443            folder_paths.clone(),
2444        );
2445
2446        cx.update(|cx| {
2447            let store = ThreadMetadataStore::global(cx);
2448            store.update(cx, |store, cx| {
2449                store.save(metadata, cx);
2450            });
2451        });
2452        cx.run_until_parked();
2453
2454        let workspace_db = cx.update(|cx| WorkspaceDb::global(cx));
2455        let workspace_id = workspace_db.next_id().await.unwrap();
2456        let serialized_paths = folder_paths.serialize();
2457        let remote_connection_id = 1_i64;
2458        workspace_db
2459            .write(move |conn| {
2460                let mut stmt = Statement::prepare(
2461                    conn,
2462                    "INSERT INTO remote_connections(id, kind, user, distro) VALUES (?1, ?2, ?3, ?4)",
2463                )?;
2464                let mut next_index = stmt.bind(&remote_connection_id, 1)?;
2465                next_index = stmt.bind(&"wsl", next_index)?;
2466                next_index = stmt.bind(&Some("anth".to_string()), next_index)?;
2467                stmt.bind(&Some("Ubuntu".to_string()), next_index)?;
2468                stmt.exec()?;
2469
2470                let mut stmt = Statement::prepare(
2471                    conn,
2472                    "UPDATE workspaces SET paths = ?2, paths_order = ?3, remote_connection_id = ?4, timestamp = CURRENT_TIMESTAMP WHERE workspace_id = ?1",
2473                )?;
2474                let mut next_index = stmt.bind(&workspace_id, 1)?;
2475                next_index = stmt.bind(&serialized_paths.paths, next_index)?;
2476                next_index = stmt.bind(&serialized_paths.order, next_index)?;
2477                stmt.bind(&Some(remote_connection_id as i32), next_index)?;
2478                stmt.exec()
2479            })
2480            .await
2481            .unwrap();
2482
2483        clear_thread_metadata_remote_connection_backfill(cx);
2484        cx.update(|cx| {
2485            migrate_thread_remote_connections(cx, Task::ready(Ok(())));
2486        });
2487        cx.run_until_parked();
2488
2489        let metadata = cx.update(|cx| {
2490            let store = ThreadMetadataStore::global(cx);
2491            store
2492                .read(cx)
2493                .entry_by_session(&acp::SessionId::new("remote-session"))
2494                .cloned()
2495                .expect("expected migrated metadata row")
2496        });
2497
2498        assert_eq!(
2499            metadata.remote_connection,
2500            Some(RemoteConnectionOptions::Wsl(WslConnectionOptions {
2501                distro_name: "Ubuntu".to_string(),
2502                user: Some("anth".to_string()),
2503            }))
2504        );
2505    }
2506
2507    #[gpui::test]
2508    async fn test_migrate_thread_metadata_archives_beyond_five_most_recent_per_project(
2509        cx: &mut TestAppContext,
2510    ) {
2511        init_test(cx);
2512
2513        let project_a_paths = PathList::new(&[Path::new("/project-a")]);
2514        let project_b_paths = PathList::new(&[Path::new("/project-b")]);
2515        let now = Utc::now();
2516
2517        // Create 7 threads for project A and 3 for project B
2518        let mut threads_to_save = Vec::new();
2519        for i in 0..7 {
2520            threads_to_save.push((
2521                format!("a-session-{i}"),
2522                format!("Thread A{i}"),
2523                project_a_paths.clone(),
2524                now + chrono::Duration::seconds(i as i64),
2525            ));
2526        }
2527        for i in 0..3 {
2528            threads_to_save.push((
2529                format!("b-session-{i}"),
2530                format!("Thread B{i}"),
2531                project_b_paths.clone(),
2532                now + chrono::Duration::seconds(i as i64),
2533            ));
2534        }
2535
2536        for (session_id, title, paths, updated_at) in &threads_to_save {
2537            let save_task = cx.update(|cx| {
2538                let thread_store = ThreadStore::global(cx);
2539                let session_id = session_id.to_string();
2540                let title = title.to_string();
2541                let paths = paths.clone();
2542                thread_store.update(cx, |store, cx| {
2543                    store.save_thread(
2544                        acp::SessionId::new(session_id),
2545                        make_db_thread(&title, *updated_at),
2546                        paths,
2547                        cx,
2548                    )
2549                })
2550            });
2551            save_task.await.unwrap();
2552            cx.run_until_parked();
2553        }
2554
2555        run_store_migrations(cx);
2556
2557        let list = cx.update(|cx| {
2558            let store = ThreadMetadataStore::global(cx);
2559            store.read(cx).entries().cloned().collect::<Vec<_>>()
2560        });
2561
2562        assert_eq!(list.len(), 10);
2563
2564        // Project A: 5 most recent should be unarchived, 2 oldest should be archived
2565        let mut project_a_entries: Vec<_> = list
2566            .iter()
2567            .filter(|m| *m.folder_paths() == project_a_paths)
2568            .collect();
2569        assert_eq!(project_a_entries.len(), 7);
2570        project_a_entries.sort_by_key(|entry| std::cmp::Reverse(entry.updated_at));
2571
2572        for entry in &project_a_entries[..5] {
2573            assert!(
2574                !entry.archived,
2575                "Expected {:?} to be unarchived (top 5 most recent)",
2576                entry.session_id
2577            );
2578        }
2579        for entry in &project_a_entries[5..] {
2580            assert!(
2581                entry.archived,
2582                "Expected {:?} to be archived (older than top 5)",
2583                entry.session_id
2584            );
2585        }
2586
2587        // Project B: all 3 should be unarchived (under the limit)
2588        let project_b_entries: Vec<_> = list
2589            .iter()
2590            .filter(|m| *m.folder_paths() == project_b_paths)
2591            .collect();
2592        assert_eq!(project_b_entries.len(), 3);
2593        assert!(project_b_entries.iter().all(|m| !m.archived));
2594    }
2595
2596    // Regression test for the race between `ThreadStore::reload` and
2597    // `migrate_thread_metadata`. `ThreadStore::new` constructs with an empty
2598    // in-memory cache and kicks off `reload()` as a fire-and-forget task. If
2599    // `migrate_thread_metadata` reads `ThreadStore::entries()` before that
2600    // reload completes, it observes an empty iterator and no-ops, even though
2601    // the on-disk legacy DB has threads to migrate. In production this
2602    // manifests as "my old threads disappeared after upgrading": the threads
2603    // are still in the legacy `threads.db`, but never make it into
2604    // `sidebar_threads`, so the new sidebar UI can't see them.
2605    #[gpui::test]
2606    async fn test_migration_awaits_thread_store_reload(cx: &mut TestAppContext) {
2607        init_test(cx);
2608
2609        // Seed the legacy threads DB via the ThreadStore (the only public
2610        // save path in this crate), then park to make sure the rows are on
2611        // disk and `ThreadStore`'s in-memory cache is populated.
2612        let project_paths = PathList::new(&[Path::new("/project-a")]);
2613        let now = Utc::now();
2614        for i in 0..3 {
2615            let save_task = cx.update(|cx| {
2616                let thread_store = ThreadStore::global(cx);
2617                let session_id = format!("legacy-session-{i}");
2618                let title = format!("Legacy Thread {i}");
2619                let updated_at = now + chrono::Duration::seconds(i as i64);
2620                let paths = project_paths.clone();
2621                thread_store.update(cx, |store, cx| {
2622                    store.save_thread(
2623                        acp::SessionId::new(session_id),
2624                        make_db_thread(&title, updated_at),
2625                        paths,
2626                        cx,
2627                    )
2628                })
2629            });
2630            save_task.await.unwrap();
2631            cx.run_until_parked();
2632        }
2633
2634        // Re-initialize `ThreadStore` so its in-memory cache is freshly empty
2635        // and a new async `reload` task is kicked off. This reproduces the
2636        // cold-boot state where the migration runs before the store has
2637        // populated itself from disk. The on-disk legacy DB still has the
2638        // three threads we saved above.
2639        cx.update(|cx| ThreadStore::init_global(cx));
2640
2641        // Crucially: do NOT run_until_parked here. If we parked, the reload
2642        // would complete, ThreadStore::entries() would return the 3 rows, and
2643        // the race would be hidden. We want the migration to run with
2644        // `ThreadStore::entries()` still returning an empty iterator.
2645        run_store_migrations(cx);
2646
2647        let list = cx.update(|cx| {
2648            let store = ThreadMetadataStore::global(cx);
2649            store.read(cx).entries().cloned().collect::<Vec<_>>()
2650        });
2651
2652        assert_eq!(
2653            list.len(),
2654            3,
2655            "Expected migration to pick up all 3 legacy threads even when \
2656             ThreadStore::reload has not yet completed, but got {} entries",
2657            list.len()
2658        );
2659    }
2660
2661    #[gpui::test]
2662    async fn test_draft_thread_metadata_promotes_on_first_message(cx: &mut TestAppContext) {
2663        init_test(cx);
2664
2665        let fs = FakeFs::new(cx.executor());
2666        let project = Project::test(fs, None::<&Path>, cx).await;
2667        let connection = StubAgentConnection::new();
2668
2669        let (panel, mut vcx) = setup_panel_with_project(project, cx);
2670        crate::test_support::open_thread_with_connection(&panel, connection, &mut vcx);
2671
2672        let thread = panel.read_with(&vcx, |panel, cx| panel.active_agent_thread(cx).unwrap());
2673        let session_id = thread.read_with(&vcx, |t, _| t.session_id().clone());
2674        let thread_id = crate::test_support::active_thread_id(&panel, &vcx);
2675
2676        // Empty (draft) threads are persisted with `session_id: None`.
2677        cx.read(|cx| {
2678            let store = ThreadMetadataStore::global(cx).read(cx);
2679            assert_eq!(store.entry_ids().count(), 1);
2680            let entry = store.entry(thread_id).expect("draft metadata row");
2681            assert!(
2682                entry.is_draft(),
2683                "expected draft row to have session_id=None, got {:?}",
2684                entry.session_id
2685            );
2686        });
2687
2688        // Updating the title while still a draft keeps the row as a draft.
2689        thread.update_in(&mut vcx, |thread, _window, cx| {
2690            thread.set_title("Draft Thread".into(), cx).detach();
2691        });
2692        vcx.run_until_parked();
2693
2694        cx.read(|cx| {
2695            let store = ThreadMetadataStore::global(cx).read(cx);
2696            let entry = store.entry(thread_id).expect("draft metadata row");
2697            assert!(entry.is_draft(), "still a draft after title update");
2698            assert_eq!(
2699                entry.title.as_ref().map(|t| t.as_ref()),
2700                Some("Draft Thread")
2701            );
2702        });
2703
2704        // Pushing content promotes the draft: session_id is now populated.
2705        thread.update_in(&mut vcx, |thread, _window, cx| {
2706            thread.push_user_content_block(None, "Hello".into(), cx);
2707        });
2708        vcx.run_until_parked();
2709
2710        cx.read(|cx| {
2711            let store = ThreadMetadataStore::global(cx).read(cx);
2712            assert_eq!(store.entry_ids().count(), 1);
2713            assert_eq!(
2714                store.entry(thread_id).unwrap().session_id.as_ref(),
2715                Some(&session_id),
2716            );
2717        });
2718    }
2719
2720    #[gpui::test]
2721    async fn test_nonempty_thread_metadata_preserved_when_thread_released(cx: &mut TestAppContext) {
2722        init_test(cx);
2723
2724        let fs = FakeFs::new(cx.executor());
2725        let project = Project::test(fs, None::<&Path>, cx).await;
2726        let connection = StubAgentConnection::new();
2727
2728        let (panel, mut vcx) = setup_panel_with_project(project, cx);
2729        crate::test_support::open_thread_with_connection(&panel, connection, &mut vcx);
2730
2731        let session_id = crate::test_support::active_session_id(&panel, &vcx);
2732        let thread = panel.read_with(&vcx, |panel, cx| panel.active_agent_thread(cx).unwrap());
2733
2734        thread.update_in(&mut vcx, |thread, _window, cx| {
2735            thread.push_user_content_block(None, "Hello".into(), cx);
2736        });
2737        vcx.run_until_parked();
2738
2739        cx.read(|cx| {
2740            let store = ThreadMetadataStore::global(cx).read(cx);
2741            assert_eq!(store.entry_ids().count(), 1);
2742            assert!(store.entry_by_session(&session_id).is_some());
2743        });
2744
2745        // Dropping the panel releases the ConversationView and its thread.
2746        drop(panel);
2747        cx.update(|_| {});
2748        cx.run_until_parked();
2749
2750        cx.read(|cx| {
2751            let store = ThreadMetadataStore::global(cx).read(cx);
2752            assert_eq!(store.entry_ids().count(), 1);
2753            assert!(store.entry_by_session(&session_id).is_some());
2754        });
2755    }
2756
2757    #[gpui::test]
2758    async fn test_threads_without_project_association_are_archived_by_default(
2759        cx: &mut TestAppContext,
2760    ) {
2761        init_test(cx);
2762
2763        let fs = FakeFs::new(cx.executor());
2764        let project_without_worktree = Project::test(fs.clone(), None::<&Path>, cx).await;
2765        let project_with_worktree = Project::test(fs, [Path::new("/project-a")], cx).await;
2766
2767        // Thread in project without worktree
2768        let (panel_no_wt, mut vcx_no_wt) = setup_panel_with_project(project_without_worktree, cx);
2769        crate::test_support::open_thread_with_connection(
2770            &panel_no_wt,
2771            StubAgentConnection::new(),
2772            &mut vcx_no_wt,
2773        );
2774        let thread_no_wt = panel_no_wt.read_with(&vcx_no_wt, |panel, cx| {
2775            panel.active_agent_thread(cx).unwrap()
2776        });
2777        thread_no_wt.update_in(&mut vcx_no_wt, |thread, _window, cx| {
2778            thread.push_user_content_block(None, "content".into(), cx);
2779            thread.set_title("No Project Thread".into(), cx).detach();
2780        });
2781        vcx_no_wt.run_until_parked();
2782        let session_without_worktree =
2783            crate::test_support::active_session_id(&panel_no_wt, &vcx_no_wt);
2784
2785        // Thread in project with worktree
2786        let (panel_wt, mut vcx_wt) = setup_panel_with_project(project_with_worktree, cx);
2787        crate::test_support::open_thread_with_connection(
2788            &panel_wt,
2789            StubAgentConnection::new(),
2790            &mut vcx_wt,
2791        );
2792        let thread_wt =
2793            panel_wt.read_with(&vcx_wt, |panel, cx| panel.active_agent_thread(cx).unwrap());
2794        thread_wt.update_in(&mut vcx_wt, |thread, _window, cx| {
2795            thread.push_user_content_block(None, "content".into(), cx);
2796            thread.set_title("Project Thread".into(), cx).detach();
2797        });
2798        vcx_wt.run_until_parked();
2799        let session_with_worktree = crate::test_support::active_session_id(&panel_wt, &vcx_wt);
2800
2801        cx.update(|cx| {
2802            let store = ThreadMetadataStore::global(cx);
2803            let store = store.read(cx);
2804
2805            let without_worktree = store
2806                .entry_by_session(&session_without_worktree)
2807                .expect("missing metadata for thread without project association");
2808            assert!(without_worktree.folder_paths().is_empty());
2809            assert!(
2810                without_worktree.archived,
2811                "expected thread without project association to be archived"
2812            );
2813
2814            let with_worktree = store
2815                .entry_by_session(&session_with_worktree)
2816                .expect("missing metadata for thread with project association");
2817            assert_eq!(
2818                *with_worktree.folder_paths(),
2819                PathList::new(&[Path::new("/project-a")])
2820            );
2821            assert!(
2822                !with_worktree.archived,
2823                "expected thread with project association to remain unarchived"
2824            );
2825        });
2826    }
2827
2828    #[gpui::test]
2829    async fn test_subagent_threads_excluded_from_sidebar_metadata(cx: &mut TestAppContext) {
2830        init_test(cx);
2831
2832        let fs = FakeFs::new(cx.executor());
2833        let project = Project::test(fs, None::<&Path>, cx).await;
2834        let connection = Rc::new(StubAgentConnection::new());
2835
2836        // Create a regular (non-subagent) thread through the panel.
2837        let (panel, mut vcx) = setup_panel_with_project(project.clone(), cx);
2838        crate::test_support::open_thread_with_connection(&panel, (*connection).clone(), &mut vcx);
2839
2840        let regular_thread =
2841            panel.read_with(&vcx, |panel, cx| panel.active_agent_thread(cx).unwrap());
2842        let regular_session_id = regular_thread.read_with(&vcx, |t, _| t.session_id().clone());
2843
2844        regular_thread.update_in(&mut vcx, |thread, _window, cx| {
2845            thread.push_user_content_block(None, "content".into(), cx);
2846            thread.set_title("Regular Thread".into(), cx).detach();
2847        });
2848        vcx.run_until_parked();
2849
2850        // Create a standalone subagent AcpThread (not wrapped in a
2851        // ConversationView). The ThreadMetadataStore only observes
2852        // ConversationView events, so this thread's events should
2853        // have no effect on sidebar metadata.
2854        let subagent_session_id = acp::SessionId::new("subagent-session");
2855        let subagent_thread = cx.update(|cx| {
2856            let action_log = cx.new(|_| ActionLog::new(project.clone()));
2857            cx.new(|cx| {
2858                acp_thread::AcpThread::new(
2859                    Some(regular_session_id.clone()),
2860                    Some("Subagent Thread".into()),
2861                    None,
2862                    connection.clone(),
2863                    project.clone(),
2864                    action_log,
2865                    subagent_session_id.clone(),
2866                    watch::Receiver::constant(acp::PromptCapabilities::new()),
2867                    cx,
2868                )
2869            })
2870        });
2871
2872        cx.update(|cx| {
2873            subagent_thread.update(cx, |thread, cx| {
2874                thread
2875                    .set_title("Subagent Thread Title".into(), cx)
2876                    .detach();
2877            });
2878        });
2879        cx.run_until_parked();
2880
2881        // Only the regular thread should appear in sidebar metadata.
2882        // The subagent thread is excluded because the metadata store
2883        // only observes ConversationView events.
2884        let list = cx.update(|cx| {
2885            let store = ThreadMetadataStore::global(cx);
2886            store.read(cx).entries().cloned().collect::<Vec<_>>()
2887        });
2888
2889        assert_eq!(
2890            list.len(),
2891            1,
2892            "Expected only the regular thread in sidebar metadata, \
2893             but found {} entries (subagent threads are leaking into the sidebar)",
2894            list.len(),
2895        );
2896        assert_eq!(list[0].session_id.as_ref().unwrap(), &regular_session_id);
2897        assert_eq!(list[0].display_title(), "Regular Thread");
2898    }
2899
2900    #[test]
2901    fn test_dedup_db_operations_keeps_latest_operation_for_session() {
2902        let now = Utc::now();
2903
2904        let meta = make_metadata("session-1", "First Thread", now, PathList::default());
2905        let thread_id = meta.thread_id;
2906        let operations = vec![DbOperation::Upsert(meta), DbOperation::Delete(thread_id)];
2907
2908        let deduped = ThreadMetadataStore::dedup_db_operations(operations);
2909
2910        assert_eq!(deduped.len(), 1);
2911        assert_eq!(deduped[0], DbOperation::Delete(thread_id));
2912    }
2913
2914    #[test]
2915    fn test_dedup_db_operations_keeps_latest_insert_for_same_session() {
2916        let now = Utc::now();
2917        let later = now + chrono::Duration::seconds(1);
2918
2919        let old_metadata = make_metadata("session-1", "Old Title", now, PathList::default());
2920        let shared_thread_id = old_metadata.thread_id;
2921        let new_metadata = ThreadMetadata {
2922            thread_id: shared_thread_id,
2923            ..make_metadata("session-1", "New Title", later, PathList::default())
2924        };
2925
2926        let deduped = ThreadMetadataStore::dedup_db_operations(vec![
2927            DbOperation::Upsert(old_metadata),
2928            DbOperation::Upsert(new_metadata.clone()),
2929        ]);
2930
2931        assert_eq!(deduped.len(), 1);
2932        assert_eq!(deduped[0], DbOperation::Upsert(new_metadata));
2933    }
2934
2935    #[test]
2936    fn test_dedup_db_operations_preserves_distinct_sessions() {
2937        let now = Utc::now();
2938
2939        let metadata1 = make_metadata("session-1", "First Thread", now, PathList::default());
2940        let metadata2 = make_metadata("session-2", "Second Thread", now, PathList::default());
2941        let deduped = ThreadMetadataStore::dedup_db_operations(vec![
2942            DbOperation::Upsert(metadata1.clone()),
2943            DbOperation::Upsert(metadata2.clone()),
2944        ]);
2945
2946        assert_eq!(deduped.len(), 2);
2947        assert!(deduped.contains(&DbOperation::Upsert(metadata1)));
2948        assert!(deduped.contains(&DbOperation::Upsert(metadata2)));
2949    }
2950
2951    #[gpui::test]
2952    async fn test_archive_and_unarchive_thread(cx: &mut TestAppContext) {
2953        init_test(cx);
2954
2955        let paths = PathList::new(&[Path::new("/project-a")]);
2956        let now = Utc::now();
2957        let metadata = make_metadata("session-1", "Thread 1", now, paths.clone());
2958        let thread_id = metadata.thread_id;
2959
2960        cx.update(|cx| {
2961            let store = ThreadMetadataStore::global(cx);
2962            store.update(cx, |store, cx| {
2963                store.save(metadata, cx);
2964            });
2965        });
2966
2967        cx.run_until_parked();
2968
2969        cx.update(|cx| {
2970            let store = ThreadMetadataStore::global(cx);
2971            let store = store.read(cx);
2972
2973            let path_entries: Vec<_> = store
2974                .entries_for_path(&paths, None)
2975                .filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
2976                .collect();
2977            assert_eq!(path_entries, vec!["session-1"]);
2978
2979            assert_eq!(store.archived_entries().count(), 0);
2980        });
2981
2982        cx.update(|cx| {
2983            let store = ThreadMetadataStore::global(cx);
2984            store.update(cx, |store, cx| {
2985                store.archive(thread_id, None, cx);
2986            });
2987        });
2988
2989        // Thread 1 should now be archived
2990        cx.run_until_parked();
2991
2992        cx.update(|cx| {
2993            let store = ThreadMetadataStore::global(cx);
2994            let store = store.read(cx);
2995
2996            let path_entries: Vec<_> = store
2997                .entries_for_path(&paths, None)
2998                .filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
2999                .collect();
3000            assert!(path_entries.is_empty());
3001
3002            let archived: Vec<_> = store.archived_entries().collect();
3003            assert_eq!(archived.len(), 1);
3004            assert_eq!(
3005                archived[0].session_id.as_ref().unwrap().0.as_ref(),
3006                "session-1"
3007            );
3008            assert!(archived[0].archived);
3009        });
3010
3011        cx.update(|cx| {
3012            let store = ThreadMetadataStore::global(cx);
3013            store.update(cx, |store, cx| {
3014                store.unarchive(thread_id, cx);
3015            });
3016        });
3017
3018        cx.run_until_parked();
3019
3020        cx.update(|cx| {
3021            let store = ThreadMetadataStore::global(cx);
3022            let store = store.read(cx);
3023
3024            let path_entries: Vec<_> = store
3025                .entries_for_path(&paths, None)
3026                .filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
3027                .collect();
3028            assert_eq!(path_entries, vec!["session-1"]);
3029
3030            assert_eq!(store.archived_entries().count(), 0);
3031        });
3032    }
3033
3034    #[gpui::test]
3035    async fn test_entries_for_path_excludes_archived(cx: &mut TestAppContext) {
3036        init_test(cx);
3037
3038        let paths = PathList::new(&[Path::new("/project-a")]);
3039        let now = Utc::now();
3040
3041        let metadata1 = make_metadata("session-1", "Active Thread", now, paths.clone());
3042        let metadata2 = make_metadata(
3043            "session-2",
3044            "Archived Thread",
3045            now - chrono::Duration::seconds(1),
3046            paths.clone(),
3047        );
3048        let session2_thread_id = metadata2.thread_id;
3049
3050        cx.update(|cx| {
3051            let store = ThreadMetadataStore::global(cx);
3052            store.update(cx, |store, cx| {
3053                store.save(metadata1, cx);
3054                store.save(metadata2, cx);
3055            });
3056        });
3057
3058        cx.run_until_parked();
3059
3060        cx.update(|cx| {
3061            let store = ThreadMetadataStore::global(cx);
3062            store.update(cx, |store, cx| {
3063                store.archive(session2_thread_id, None, cx);
3064            });
3065        });
3066
3067        cx.run_until_parked();
3068
3069        cx.update(|cx| {
3070            let store = ThreadMetadataStore::global(cx);
3071            let store = store.read(cx);
3072
3073            let path_entries: Vec<_> = store
3074                .entries_for_path(&paths, None)
3075                .filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
3076                .collect();
3077            assert_eq!(path_entries, vec!["session-1"]);
3078
3079            assert_eq!(store.entries().count(), 2);
3080
3081            let archived: Vec<_> = store
3082                .archived_entries()
3083                .filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
3084                .collect();
3085            assert_eq!(archived, vec!["session-2"]);
3086        });
3087    }
3088
3089    #[gpui::test]
3090    async fn test_entries_filter_by_remote_connection(cx: &mut TestAppContext) {
3091        init_test(cx);
3092
3093        let main_paths = PathList::new(&[Path::new("/project-a")]);
3094        let linked_paths = PathList::new(&[Path::new("/wt-feature")]);
3095        let now = Utc::now();
3096
3097        let remote_a = RemoteConnectionOptions::Mock(remote::MockConnectionOptions { id: 1 });
3098        let remote_b = RemoteConnectionOptions::Mock(remote::MockConnectionOptions { id: 2 });
3099
3100        // Three threads at the same folder_paths but different hosts.
3101        let local_thread = make_metadata("local-session", "Local Thread", now, main_paths.clone());
3102
3103        let mut remote_a_thread = make_metadata(
3104            "remote-a-session",
3105            "Remote A Thread",
3106            now - chrono::Duration::seconds(1),
3107            main_paths.clone(),
3108        );
3109        remote_a_thread.remote_connection = Some(remote_a.clone());
3110
3111        let mut remote_b_thread = make_metadata(
3112            "remote-b-session",
3113            "Remote B Thread",
3114            now - chrono::Duration::seconds(2),
3115            main_paths.clone(),
3116        );
3117        remote_b_thread.remote_connection = Some(remote_b.clone());
3118
3119        let linked_worktree_paths =
3120            WorktreePaths::from_path_lists(main_paths.clone(), linked_paths).unwrap();
3121
3122        let local_linked_thread = ThreadMetadata {
3123            thread_id: ThreadId::new(),
3124            archived: false,
3125            session_id: Some(acp::SessionId::new("local-linked")),
3126            agent_id: agent::OMEGA_AGENT_ID.clone(),
3127            title: Some("Local Linked".into()),
3128            title_override: None,
3129            updated_at: now,
3130            created_at: Some(now),
3131            interacted_at: None,
3132            worktree_paths: linked_worktree_paths.clone(),
3133            remote_connection: None,
3134        };
3135
3136        let remote_linked_thread = ThreadMetadata {
3137            thread_id: ThreadId::new(),
3138            archived: false,
3139            session_id: Some(acp::SessionId::new("remote-linked")),
3140            agent_id: agent::OMEGA_AGENT_ID.clone(),
3141            title: Some("Remote Linked".into()),
3142            title_override: None,
3143            updated_at: now - chrono::Duration::seconds(1),
3144            created_at: Some(now - chrono::Duration::seconds(1)),
3145            interacted_at: None,
3146            worktree_paths: linked_worktree_paths,
3147            remote_connection: Some(remote_a.clone()),
3148        };
3149
3150        cx.update(|cx| {
3151            let store = ThreadMetadataStore::global(cx);
3152            store.update(cx, |store, cx| {
3153                store.save(local_thread, cx);
3154                store.save(remote_a_thread, cx);
3155                store.save(remote_b_thread, cx);
3156                store.save(local_linked_thread, cx);
3157                store.save(remote_linked_thread, cx);
3158            });
3159        });
3160        cx.run_until_parked();
3161
3162        cx.update(|cx| {
3163            let store = ThreadMetadataStore::global(cx);
3164            let store = store.read(cx);
3165
3166            let local_entries: Vec<_> = store
3167                .entries_for_path(&main_paths, None)
3168                .filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
3169                .collect();
3170            assert_eq!(local_entries, vec!["local-session"]);
3171
3172            let remote_a_entries: Vec<_> = store
3173                .entries_for_path(&main_paths, Some(&remote_a))
3174                .filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
3175                .collect();
3176            assert_eq!(remote_a_entries, vec!["remote-a-session"]);
3177
3178            let remote_b_entries: Vec<_> = store
3179                .entries_for_path(&main_paths, Some(&remote_b))
3180                .filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
3181                .collect();
3182            assert_eq!(remote_b_entries, vec!["remote-b-session"]);
3183
3184            let mut local_main_entries: Vec<_> = store
3185                .entries_for_main_worktree_path(&main_paths, None)
3186                .filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
3187                .collect();
3188            local_main_entries.sort();
3189            assert_eq!(local_main_entries, vec!["local-linked", "local-session"]);
3190
3191            let mut remote_main_entries: Vec<_> = store
3192                .entries_for_main_worktree_path(&main_paths, Some(&remote_a))
3193                .filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
3194                .collect();
3195            remote_main_entries.sort();
3196            assert_eq!(
3197                remote_main_entries,
3198                vec!["remote-a-session", "remote-linked"]
3199            );
3200        });
3201    }
3202
3203    #[gpui::test]
3204    async fn test_save_all_persists_multiple_threads(cx: &mut TestAppContext) {
3205        init_test(cx);
3206
3207        let paths = PathList::new(&[Path::new("/project-a")]);
3208        let now = Utc::now();
3209
3210        let m1 = make_metadata("session-1", "Thread One", now, paths.clone());
3211        let m2 = make_metadata(
3212            "session-2",
3213            "Thread Two",
3214            now - chrono::Duration::seconds(1),
3215            paths.clone(),
3216        );
3217        let m3 = make_metadata(
3218            "session-3",
3219            "Thread Three",
3220            now - chrono::Duration::seconds(2),
3221            paths,
3222        );
3223
3224        cx.update(|cx| {
3225            let store = ThreadMetadataStore::global(cx);
3226            store.update(cx, |store, cx| {
3227                store.save_all(vec![m1, m2, m3], cx);
3228            });
3229        });
3230
3231        cx.run_until_parked();
3232
3233        cx.update(|cx| {
3234            let store = ThreadMetadataStore::global(cx);
3235            let store = store.read(cx);
3236
3237            assert_eq!(store.entries().count(), 3);
3238            assert!(
3239                store
3240                    .entry_by_session(&acp::SessionId::new("session-1"))
3241                    .is_some()
3242            );
3243            assert!(
3244                store
3245                    .entry_by_session(&acp::SessionId::new("session-2"))
3246                    .is_some()
3247            );
3248            assert!(
3249                store
3250                    .entry_by_session(&acp::SessionId::new("session-3"))
3251                    .is_some()
3252            );
3253
3254            assert_eq!(store.entry_ids().count(), 3);
3255        });
3256    }
3257
3258    #[gpui::test]
3259    async fn test_archived_flag_persists_across_reload(cx: &mut TestAppContext) {
3260        init_test(cx);
3261
3262        let paths = PathList::new(&[Path::new("/project-a")]);
3263        let now = Utc::now();
3264        let metadata = make_metadata("session-1", "Thread 1", now, paths.clone());
3265        let thread_id = metadata.thread_id;
3266
3267        cx.update(|cx| {
3268            let store = ThreadMetadataStore::global(cx);
3269            store.update(cx, |store, cx| {
3270                store.save(metadata, cx);
3271            });
3272        });
3273
3274        cx.run_until_parked();
3275
3276        cx.update(|cx| {
3277            let store = ThreadMetadataStore::global(cx);
3278            store.update(cx, |store, cx| {
3279                store.archive(thread_id, None, cx);
3280            });
3281        });
3282
3283        cx.run_until_parked();
3284
3285        cx.update(|cx| {
3286            let store = ThreadMetadataStore::global(cx);
3287            store.update(cx, |store, cx| {
3288                let _ = store.reload(cx);
3289            });
3290        });
3291
3292        cx.run_until_parked();
3293
3294        cx.update(|cx| {
3295            let store = ThreadMetadataStore::global(cx);
3296            let store = store.read(cx);
3297
3298            let thread = store
3299                .entry_by_session(&acp::SessionId::new("session-1"))
3300                .expect("thread should exist after reload");
3301            assert!(thread.archived);
3302
3303            let path_entries: Vec<_> = store
3304                .entries_for_path(&paths, None)
3305                .filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
3306                .collect();
3307            assert!(path_entries.is_empty());
3308
3309            let archived: Vec<_> = store
3310                .archived_entries()
3311                .filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
3312                .collect();
3313            assert_eq!(archived, vec!["session-1"]);
3314        });
3315    }
3316
3317    #[gpui::test]
3318    async fn test_archive_nonexistent_thread_is_noop(cx: &mut TestAppContext) {
3319        init_test(cx);
3320
3321        cx.run_until_parked();
3322
3323        cx.update(|cx| {
3324            let store = ThreadMetadataStore::global(cx);
3325            store.update(cx, |store, cx| {
3326                store.archive(ThreadId::new(), None, cx);
3327            });
3328        });
3329
3330        cx.run_until_parked();
3331
3332        cx.update(|cx| {
3333            let store = ThreadMetadataStore::global(cx);
3334            let store = store.read(cx);
3335
3336            assert!(store.is_empty());
3337            assert_eq!(store.entries().count(), 0);
3338            assert_eq!(store.archived_entries().count(), 0);
3339        });
3340    }
3341
3342    #[gpui::test]
3343    async fn test_save_followed_by_archiving_without_parking(cx: &mut TestAppContext) {
3344        init_test(cx);
3345
3346        let paths = PathList::new(&[Path::new("/project-a")]);
3347        let now = Utc::now();
3348        let metadata = make_metadata("session-1", "Thread 1", now, paths);
3349        let thread_id = metadata.thread_id;
3350
3351        cx.update(|cx| {
3352            let store = ThreadMetadataStore::global(cx);
3353            store.update(cx, |store, cx| {
3354                store.save(metadata.clone(), cx);
3355                store.archive(thread_id, None, cx);
3356            });
3357        });
3358
3359        cx.run_until_parked();
3360
3361        cx.update(|cx| {
3362            let store = ThreadMetadataStore::global(cx);
3363            let store = store.read(cx);
3364
3365            let entries: Vec<ThreadMetadata> = store.entries().cloned().collect();
3366            pretty_assertions::assert_eq!(
3367                entries,
3368                vec![ThreadMetadata {
3369                    archived: true,
3370                    ..metadata
3371                }]
3372            );
3373        });
3374    }
3375
3376    #[gpui::test]
3377    async fn test_create_and_retrieve_archived_worktree(cx: &mut TestAppContext) {
3378        init_test(cx);
3379        let store = cx.update(|cx| ThreadMetadataStore::global(cx));
3380
3381        let id = store
3382            .read_with(cx, |store, cx| {
3383                store.create_archived_worktree(
3384                    "/tmp/worktree".to_string(),
3385                    "/home/user/repo".to_string(),
3386                    Some("feature-branch".to_string()),
3387                    "staged_aaa".to_string(),
3388                    "unstaged_bbb".to_string(),
3389                    "original_000".to_string(),
3390                    cx,
3391                )
3392            })
3393            .await
3394            .unwrap();
3395
3396        let thread_id_1 = ThreadId::new();
3397
3398        store
3399            .read_with(cx, |store, cx| {
3400                store.link_thread_to_archived_worktree(thread_id_1, id, cx)
3401            })
3402            .await
3403            .unwrap();
3404
3405        let worktrees = store
3406            .read_with(cx, |store, cx| {
3407                store.get_archived_worktrees_for_thread(thread_id_1, cx)
3408            })
3409            .await
3410            .unwrap();
3411
3412        assert_eq!(worktrees.len(), 1);
3413        let wt = &worktrees[0];
3414        assert_eq!(wt.id, id);
3415        assert_eq!(wt.worktree_path, PathBuf::from("/tmp/worktree"));
3416        assert_eq!(wt.main_repo_path, PathBuf::from("/home/user/repo"));
3417        assert_eq!(wt.branch_name.as_deref(), Some("feature-branch"));
3418        assert_eq!(wt.staged_commit_hash, "staged_aaa");
3419        assert_eq!(wt.unstaged_commit_hash, "unstaged_bbb");
3420        assert_eq!(wt.original_commit_hash, "original_000");
3421    }
3422
3423    #[gpui::test]
3424    async fn test_delete_archived_worktree(cx: &mut TestAppContext) {
3425        init_test(cx);
3426        let store = cx.update(|cx| ThreadMetadataStore::global(cx));
3427
3428        let id = store
3429            .read_with(cx, |store, cx| {
3430                store.create_archived_worktree(
3431                    "/tmp/worktree".to_string(),
3432                    "/home/user/repo".to_string(),
3433                    Some("main".to_string()),
3434                    "deadbeef".to_string(),
3435                    "deadbeef".to_string(),
3436                    "original_000".to_string(),
3437                    cx,
3438                )
3439            })
3440            .await
3441            .unwrap();
3442
3443        let thread_id_1 = ThreadId::new();
3444
3445        store
3446            .read_with(cx, |store, cx| {
3447                store.link_thread_to_archived_worktree(thread_id_1, id, cx)
3448            })
3449            .await
3450            .unwrap();
3451
3452        store
3453            .read_with(cx, |store, cx| store.delete_archived_worktree(id, cx))
3454            .await
3455            .unwrap();
3456
3457        let worktrees = store
3458            .read_with(cx, |store, cx| {
3459                store.get_archived_worktrees_for_thread(thread_id_1, cx)
3460            })
3461            .await
3462            .unwrap();
3463        assert!(worktrees.is_empty());
3464    }
3465
3466    #[gpui::test]
3467    async fn test_link_multiple_threads_to_archived_worktree(cx: &mut TestAppContext) {
3468        init_test(cx);
3469        let store = cx.update(|cx| ThreadMetadataStore::global(cx));
3470
3471        let id = store
3472            .read_with(cx, |store, cx| {
3473                store.create_archived_worktree(
3474                    "/tmp/worktree".to_string(),
3475                    "/home/user/repo".to_string(),
3476                    None,
3477                    "abc123".to_string(),
3478                    "abc123".to_string(),
3479                    "original_000".to_string(),
3480                    cx,
3481                )
3482            })
3483            .await
3484            .unwrap();
3485
3486        let thread_id_1 = ThreadId::new();
3487        let thread_id_2 = ThreadId::new();
3488
3489        store
3490            .read_with(cx, |store, cx| {
3491                store.link_thread_to_archived_worktree(thread_id_1, id, cx)
3492            })
3493            .await
3494            .unwrap();
3495
3496        store
3497            .read_with(cx, |store, cx| {
3498                store.link_thread_to_archived_worktree(thread_id_2, id, cx)
3499            })
3500            .await
3501            .unwrap();
3502
3503        let wt1 = store
3504            .read_with(cx, |store, cx| {
3505                store.get_archived_worktrees_for_thread(thread_id_1, cx)
3506            })
3507            .await
3508            .unwrap();
3509
3510        let wt2 = store
3511            .read_with(cx, |store, cx| {
3512                store.get_archived_worktrees_for_thread(thread_id_2, cx)
3513            })
3514            .await
3515            .unwrap();
3516
3517        assert_eq!(wt1.len(), 1);
3518        assert_eq!(wt2.len(), 1);
3519        assert_eq!(wt1[0].id, wt2[0].id);
3520    }
3521
3522    #[gpui::test]
3523    async fn test_complete_worktree_restore_multiple_paths(cx: &mut TestAppContext) {
3524        init_test(cx);
3525        let store = cx.update(|cx| ThreadMetadataStore::global(cx));
3526
3527        let original_paths = PathList::new(&[
3528            Path::new("/projects/worktree-a"),
3529            Path::new("/projects/worktree-b"),
3530            Path::new("/other/unrelated"),
3531        ]);
3532        let meta = make_metadata("session-multi", "Multi Thread", Utc::now(), original_paths);
3533        let thread_id = meta.thread_id;
3534
3535        store.update(cx, |store, cx| {
3536            store.save(meta, cx);
3537        });
3538
3539        let replacements = vec![
3540            (
3541                PathBuf::from("/projects/worktree-a"),
3542                PathBuf::from("/restored/worktree-a"),
3543            ),
3544            (
3545                PathBuf::from("/projects/worktree-b"),
3546                PathBuf::from("/restored/worktree-b"),
3547            ),
3548        ];
3549
3550        store.update(cx, |store, cx| {
3551            store.complete_worktree_restore(thread_id, &replacements, cx);
3552        });
3553
3554        let entry = store.read_with(cx, |store, _cx| store.entry(thread_id).cloned());
3555        let entry = entry.unwrap();
3556        let paths = entry.folder_paths().paths();
3557        assert_eq!(paths.len(), 3);
3558        assert!(paths.contains(&PathBuf::from("/restored/worktree-a")));
3559        assert!(paths.contains(&PathBuf::from("/restored/worktree-b")));
3560        assert!(paths.contains(&PathBuf::from("/other/unrelated")));
3561    }
3562
3563    #[gpui::test]
3564    async fn test_complete_worktree_restore_preserves_unmatched_paths(cx: &mut TestAppContext) {
3565        init_test(cx);
3566        let store = cx.update(|cx| ThreadMetadataStore::global(cx));
3567
3568        let original_paths =
3569            PathList::new(&[Path::new("/projects/worktree-a"), Path::new("/other/path")]);
3570        let meta = make_metadata("session-partial", "Partial", Utc::now(), original_paths);
3571        let thread_id = meta.thread_id;
3572
3573        store.update(cx, |store, cx| {
3574            store.save(meta, cx);
3575        });
3576
3577        let replacements = vec![
3578            (
3579                PathBuf::from("/projects/worktree-a"),
3580                PathBuf::from("/new/worktree-a"),
3581            ),
3582            (
3583                PathBuf::from("/nonexistent/path"),
3584                PathBuf::from("/should/not/appear"),
3585            ),
3586        ];
3587
3588        store.update(cx, |store, cx| {
3589            store.complete_worktree_restore(thread_id, &replacements, cx);
3590        });
3591
3592        let entry = store.read_with(cx, |store, _cx| store.entry(thread_id).cloned());
3593        let entry = entry.unwrap();
3594        let paths = entry.folder_paths().paths();
3595        assert_eq!(paths.len(), 2);
3596        assert!(paths.contains(&PathBuf::from("/new/worktree-a")));
3597        assert!(paths.contains(&PathBuf::from("/other/path")));
3598        assert!(!paths.contains(&PathBuf::from("/should/not/appear")));
3599    }
3600
3601    #[gpui::test]
3602    async fn test_update_restored_worktree_paths_multiple(cx: &mut TestAppContext) {
3603        init_test(cx);
3604        let store = cx.update(|cx| ThreadMetadataStore::global(cx));
3605
3606        let original_paths = PathList::new(&[
3607            Path::new("/projects/worktree-a"),
3608            Path::new("/projects/worktree-b"),
3609            Path::new("/other/unrelated"),
3610        ]);
3611        let meta = make_metadata("session-multi", "Multi Thread", Utc::now(), original_paths);
3612        let thread_id = meta.thread_id;
3613
3614        store.update(cx, |store, cx| {
3615            store.save(meta, cx);
3616        });
3617
3618        let replacements = vec![
3619            (
3620                PathBuf::from("/projects/worktree-a"),
3621                PathBuf::from("/restored/worktree-a"),
3622            ),
3623            (
3624                PathBuf::from("/projects/worktree-b"),
3625                PathBuf::from("/restored/worktree-b"),
3626            ),
3627        ];
3628
3629        store.update(cx, |store, cx| {
3630            store.update_restored_worktree_paths(thread_id, &replacements, cx);
3631        });
3632
3633        let entry = store.read_with(cx, |store, _cx| store.entry(thread_id).cloned());
3634        let entry = entry.unwrap();
3635        let paths = entry.folder_paths().paths();
3636        assert_eq!(paths.len(), 3);
3637        assert!(paths.contains(&PathBuf::from("/restored/worktree-a")));
3638        assert!(paths.contains(&PathBuf::from("/restored/worktree-b")));
3639        assert!(paths.contains(&PathBuf::from("/other/unrelated")));
3640    }
3641
3642    #[gpui::test]
3643    async fn test_update_restored_worktree_paths_preserves_unmatched(cx: &mut TestAppContext) {
3644        init_test(cx);
3645        let store = cx.update(|cx| ThreadMetadataStore::global(cx));
3646
3647        let original_paths =
3648            PathList::new(&[Path::new("/projects/worktree-a"), Path::new("/other/path")]);
3649        let meta = make_metadata("session-partial", "Partial", Utc::now(), original_paths);
3650        let thread_id = meta.thread_id;
3651
3652        store.update(cx, |store, cx| {
3653            store.save(meta, cx);
3654        });
3655
3656        let replacements = vec![
3657            (
3658                PathBuf::from("/projects/worktree-a"),
3659                PathBuf::from("/new/worktree-a"),
3660            ),
3661            (
3662                PathBuf::from("/nonexistent/path"),
3663                PathBuf::from("/should/not/appear"),
3664            ),
3665        ];
3666
3667        store.update(cx, |store, cx| {
3668            store.update_restored_worktree_paths(thread_id, &replacements, cx);
3669        });
3670
3671        let entry = store.read_with(cx, |store, _cx| store.entry(thread_id).cloned());
3672        let entry = entry.unwrap();
3673        let paths = entry.folder_paths().paths();
3674        assert_eq!(paths.len(), 2);
3675        assert!(paths.contains(&PathBuf::from("/new/worktree-a")));
3676        assert!(paths.contains(&PathBuf::from("/other/path")));
3677        assert!(!paths.contains(&PathBuf::from("/should/not/appear")));
3678    }
3679
3680    #[gpui::test]
3681    async fn test_multiple_archived_worktrees_per_thread(cx: &mut TestAppContext) {
3682        init_test(cx);
3683        let store = cx.update(|cx| ThreadMetadataStore::global(cx));
3684
3685        let id1 = store
3686            .read_with(cx, |store, cx| {
3687                store.create_archived_worktree(
3688                    "/projects/worktree-a".to_string(),
3689                    "/home/user/repo".to_string(),
3690                    Some("branch-a".to_string()),
3691                    "staged_a".to_string(),
3692                    "unstaged_a".to_string(),
3693                    "original_000".to_string(),
3694                    cx,
3695                )
3696            })
3697            .await
3698            .unwrap();
3699
3700        let id2 = store
3701            .read_with(cx, |store, cx| {
3702                store.create_archived_worktree(
3703                    "/projects/worktree-b".to_string(),
3704                    "/home/user/repo".to_string(),
3705                    Some("branch-b".to_string()),
3706                    "staged_b".to_string(),
3707                    "unstaged_b".to_string(),
3708                    "original_000".to_string(),
3709                    cx,
3710                )
3711            })
3712            .await
3713            .unwrap();
3714
3715        let thread_id_1 = ThreadId::new();
3716
3717        store
3718            .read_with(cx, |store, cx| {
3719                store.link_thread_to_archived_worktree(thread_id_1, id1, cx)
3720            })
3721            .await
3722            .unwrap();
3723
3724        store
3725            .read_with(cx, |store, cx| {
3726                store.link_thread_to_archived_worktree(thread_id_1, id2, cx)
3727            })
3728            .await
3729            .unwrap();
3730
3731        let worktrees = store
3732            .read_with(cx, |store, cx| {
3733                store.get_archived_worktrees_for_thread(thread_id_1, cx)
3734            })
3735            .await
3736            .unwrap();
3737
3738        assert_eq!(worktrees.len(), 2);
3739
3740        let paths: Vec<&Path> = worktrees
3741            .iter()
3742            .map(|w| w.worktree_path.as_path())
3743            .collect();
3744        assert!(paths.contains(&Path::new("/projects/worktree-a")));
3745        assert!(paths.contains(&Path::new("/projects/worktree-b")));
3746    }
3747
3748    // ── Migration tests ────────────────────────────────────────────────
3749
3750    #[test]
3751    fn test_thread_id_primary_key_migration_backfills_null_thread_ids() {
3752        use db::sqlez::connection::Connection;
3753
3754        let connection =
3755            Connection::open_memory(Some("test_thread_id_pk_migration_backfills_nulls"));
3756
3757        // Run migrations 0-6 (the old schema, before the thread_id PK migration).
3758        let old_migrations: &[&str] = &ThreadMetadataDb::MIGRATIONS[..7];
3759        connection
3760            .migrate(ThreadMetadataDb::NAME, old_migrations, &mut |_, _, _| false)
3761            .expect("old migrations should succeed");
3762
3763        // Insert rows: one with a thread_id, two without.
3764        connection
3765            .exec(
3766                "INSERT INTO sidebar_threads \
3767                 (session_id, title, updated_at, thread_id) \
3768                 VALUES ('has-tid', 'Has ThreadId', '2025-01-01T00:00:00Z', X'0102030405060708090A0B0C0D0E0F10')",
3769            )
3770            .unwrap()()
3771            .unwrap();
3772        connection
3773            .exec(
3774                "INSERT INTO sidebar_threads \
3775                 (session_id, title, updated_at) \
3776                 VALUES ('no-tid-1', 'No ThreadId 1', '2025-01-02T00:00:00Z')",
3777            )
3778            .unwrap()()
3779        .unwrap();
3780        connection
3781            .exec(
3782                "INSERT INTO sidebar_threads \
3783                 (session_id, title, updated_at) \
3784                 VALUES ('no-tid-2', 'No ThreadId 2', '2025-01-03T00:00:00Z')",
3785            )
3786            .unwrap()()
3787        .unwrap();
3788
3789        // Set up archived_git_worktrees + thread_archived_worktrees rows
3790        // referencing the session without a thread_id.
3791        connection
3792            .exec(
3793                "INSERT INTO archived_git_worktrees \
3794                 (id, worktree_path, main_repo_path, staged_commit_hash, unstaged_commit_hash, original_commit_hash) \
3795                 VALUES (1, '/wt', '/main', 'abc', 'def', '000')",
3796            )
3797            .unwrap()()
3798            .unwrap();
3799        connection
3800            .exec(
3801                "INSERT INTO thread_archived_worktrees \
3802                 (session_id, archived_worktree_id) \
3803                 VALUES ('no-tid-1', 1)",
3804            )
3805            .unwrap()()
3806        .unwrap();
3807
3808        // Run all current migrations. sqlez skips the already-applied ones and
3809        // runs the remaining migrations.
3810        run_thread_metadata_migrations(&connection);
3811
3812        // All 3 rows should survive with non-NULL thread_ids.
3813        let count: i64 = connection
3814            .select_row_bound::<(), i64>("SELECT COUNT(*) FROM sidebar_threads")
3815            .unwrap()(())
3816        .unwrap()
3817        .unwrap();
3818        assert_eq!(count, 3, "all 3 rows should survive the migration");
3819
3820        let null_count: i64 = connection
3821            .select_row_bound::<(), i64>(
3822                "SELECT COUNT(*) FROM sidebar_threads WHERE thread_id IS NULL",
3823            )
3824            .unwrap()(())
3825        .unwrap()
3826        .unwrap();
3827        assert_eq!(
3828            null_count, 0,
3829            "no rows should have NULL thread_id after migration"
3830        );
3831
3832        // The row that already had a thread_id should keep its original value.
3833        let original_tid: Vec<u8> = connection
3834            .select_row_bound::<&str, Vec<u8>>(
3835                "SELECT thread_id FROM sidebar_threads WHERE session_id = ?",
3836            )
3837            .unwrap()("has-tid")
3838        .unwrap()
3839        .unwrap();
3840        assert_eq!(
3841            original_tid,
3842            vec![
3843                0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
3844                0x0F, 0x10
3845            ],
3846            "pre-existing thread_id should be preserved"
3847        );
3848
3849        // The two rows that had NULL thread_ids should now have distinct non-empty blobs.
3850        let generated_tid_1: Vec<u8> = connection
3851            .select_row_bound::<&str, Vec<u8>>(
3852                "SELECT thread_id FROM sidebar_threads WHERE session_id = ?",
3853            )
3854            .unwrap()("no-tid-1")
3855        .unwrap()
3856        .unwrap();
3857        let generated_tid_2: Vec<u8> = connection
3858            .select_row_bound::<&str, Vec<u8>>(
3859                "SELECT thread_id FROM sidebar_threads WHERE session_id = ?",
3860            )
3861            .unwrap()("no-tid-2")
3862        .unwrap()
3863        .unwrap();
3864        assert_eq!(
3865            generated_tid_1.len(),
3866            16,
3867            "generated thread_id should be 16 bytes"
3868        );
3869        assert_eq!(
3870            generated_tid_2.len(),
3871            16,
3872            "generated thread_id should be 16 bytes"
3873        );
3874        assert_ne!(
3875            generated_tid_1, generated_tid_2,
3876            "each generated thread_id should be unique"
3877        );
3878
3879        // The thread_archived_worktrees join row should have migrated
3880        // using the backfilled thread_id from the session without a
3881        // pre-existing thread_id.
3882        let archived_count: i64 = connection
3883            .select_row_bound::<(), i64>("SELECT COUNT(*) FROM thread_archived_worktrees")
3884            .unwrap()(())
3885        .unwrap()
3886        .unwrap();
3887        assert_eq!(
3888            archived_count, 1,
3889            "thread_archived_worktrees row should survive migration"
3890        );
3891
3892        // The thread_archived_worktrees row should reference the
3893        // backfilled thread_id of the 'no-tid-1' session.
3894        let archived_tid: Vec<u8> = connection
3895            .select_row_bound::<(), Vec<u8>>(
3896                "SELECT thread_id FROM thread_archived_worktrees LIMIT 1",
3897            )
3898            .unwrap()(())
3899        .unwrap()
3900        .unwrap();
3901        assert_eq!(
3902            archived_tid, generated_tid_1,
3903            "thread_archived_worktrees should reference the backfilled thread_id"
3904        );
3905    }
3906
3907    // ── ThreadWorktreePaths tests ──────────────────────────────────────
3908
3909    /// Helper to build a `ThreadWorktreePaths` from (main, folder) pairs.
3910    fn make_worktree_paths(pairs: &[(&str, &str)]) -> WorktreePaths {
3911        let (mains, folders): (Vec<&Path>, Vec<&Path>) = pairs
3912            .iter()
3913            .map(|(m, f)| (Path::new(*m), Path::new(*f)))
3914            .unzip();
3915        WorktreePaths::from_path_lists(PathList::new(&mains), PathList::new(&folders)).unwrap()
3916    }
3917
3918    #[test]
3919    fn test_thread_worktree_paths_full_add_then_remove_cycle() {
3920        // Full scenario from the issue:
3921        //   1. Start with linked worktree selectric → zed
3922        //   2. Add cloud
3923        //   3. Remove zed
3924
3925        let mut paths = make_worktree_paths(&[("/projects/zed", "/worktrees/selectric/zed")]);
3926
3927        // Step 2: add cloud
3928        paths.add_path(Path::new("/projects/cloud"), Path::new("/projects/cloud"));
3929
3930        assert_eq!(paths.ordered_pairs().count(), 2);
3931        assert_eq!(
3932            paths.folder_path_list(),
3933            &PathList::new(&[
3934                Path::new("/worktrees/selectric/zed"),
3935                Path::new("/projects/cloud"),
3936            ])
3937        );
3938        assert_eq!(
3939            paths.main_worktree_path_list(),
3940            &PathList::new(&[Path::new("/projects/zed"), Path::new("/projects/cloud"),])
3941        );
3942
3943        // Step 3: remove zed
3944        paths.remove_main_path(Path::new("/projects/zed"));
3945
3946        assert_eq!(paths.ordered_pairs().count(), 1);
3947        assert_eq!(
3948            paths.folder_path_list(),
3949            &PathList::new(&[Path::new("/projects/cloud")])
3950        );
3951        assert_eq!(
3952            paths.main_worktree_path_list(),
3953            &PathList::new(&[Path::new("/projects/cloud")])
3954        );
3955    }
3956
3957    #[test]
3958    fn test_thread_worktree_paths_add_is_idempotent() {
3959        let mut paths = make_worktree_paths(&[("/projects/zed", "/projects/zed")]);
3960
3961        paths.add_path(Path::new("/projects/zed"), Path::new("/projects/zed"));
3962
3963        assert_eq!(paths.ordered_pairs().count(), 1);
3964    }
3965
3966    #[test]
3967    fn test_thread_worktree_paths_remove_nonexistent_is_noop() {
3968        let mut paths = make_worktree_paths(&[("/projects/zed", "/worktrees/selectric/zed")]);
3969
3970        paths.remove_main_path(Path::new("/projects/nonexistent"));
3971
3972        assert_eq!(paths.ordered_pairs().count(), 1);
3973    }
3974
3975    #[test]
3976    fn test_thread_worktree_paths_from_path_lists_preserves_association() {
3977        let folder = PathList::new(&[
3978            Path::new("/worktrees/selectric/zed"),
3979            Path::new("/projects/cloud"),
3980        ]);
3981        let main = PathList::new(&[Path::new("/projects/zed"), Path::new("/projects/cloud")]);
3982
3983        let paths = WorktreePaths::from_path_lists(main, folder).unwrap();
3984
3985        let pairs: Vec<_> = paths
3986            .ordered_pairs()
3987            .map(|(m, f)| (m.clone(), f.clone()))
3988            .collect();
3989        assert_eq!(pairs.len(), 2);
3990        assert!(pairs.contains(&(
3991            PathBuf::from("/projects/zed"),
3992            PathBuf::from("/worktrees/selectric/zed")
3993        )));
3994        assert!(pairs.contains(&(
3995            PathBuf::from("/projects/cloud"),
3996            PathBuf::from("/projects/cloud")
3997        )));
3998    }
3999
4000    #[test]
4001    fn test_thread_worktree_paths_main_deduplicates_linked_worktrees() {
4002        // Two linked worktrees of the same main repo: the main_worktree_path_list
4003        // deduplicates because PathList stores unique sorted paths, but
4004        // ordered_pairs still has both entries.
4005        let paths = make_worktree_paths(&[
4006            ("/projects/zed", "/worktrees/selectric/zed"),
4007            ("/projects/zed", "/worktrees/feature/zed"),
4008        ]);
4009
4010        // main_worktree_path_list has the duplicate main path twice
4011        // (PathList keeps all entries from its input)
4012        assert_eq!(paths.ordered_pairs().count(), 2);
4013        assert_eq!(
4014            paths.folder_path_list(),
4015            &PathList::new(&[
4016                Path::new("/worktrees/selectric/zed"),
4017                Path::new("/worktrees/feature/zed"),
4018            ])
4019        );
4020        assert_eq!(
4021            paths.main_worktree_path_list(),
4022            &PathList::new(&[Path::new("/projects/zed"), Path::new("/projects/zed"),])
4023        );
4024    }
4025
4026    #[test]
4027    fn test_thread_worktree_paths_mismatched_lengths_returns_error() {
4028        let folder = PathList::new(&[
4029            Path::new("/worktrees/selectric/zed"),
4030            Path::new("/projects/cloud"),
4031        ]);
4032        let main = PathList::new(&[Path::new("/projects/zed")]);
4033
4034        let result = WorktreePaths::from_path_lists(main, folder);
4035        assert!(result.is_err());
4036    }
4037
4038    /// Regression test: archiving a thread created in a git worktree must
4039    /// preserve the thread's folder paths so that restoring it later does
4040    /// not prompt the user to re-associate a project.
4041    #[gpui::test]
4042    async fn test_archived_thread_retains_paths_after_worktree_removal(cx: &mut TestAppContext) {
4043        init_test(cx);
4044
4045        let fs = FakeFs::new(cx.executor());
4046        fs.insert_tree(
4047            "/worktrees/feature",
4048            serde_json::json!({ "src": { "main.rs": "" } }),
4049        )
4050        .await;
4051        let project = Project::test(fs, [Path::new("/worktrees/feature")], cx).await;
4052        let connection = StubAgentConnection::new();
4053
4054        let (panel, mut vcx) = setup_panel_with_project(project.clone(), cx);
4055        crate::test_support::open_thread_with_connection(&panel, connection, &mut vcx);
4056
4057        let thread = panel.read_with(&vcx, |panel, cx| panel.active_agent_thread(cx).unwrap());
4058        let thread_id = crate::test_support::active_thread_id(&panel, &vcx);
4059
4060        // Push content so the event handler saves metadata with the
4061        // project's worktree paths.
4062        thread.update_in(&mut vcx, |thread, _window, cx| {
4063            thread.push_user_content_block(None, "Hello".into(), cx);
4064        });
4065        vcx.run_until_parked();
4066
4067        // Verify paths were saved correctly.
4068        let (folder_paths_before, main_paths_before) = cx.read(|cx| {
4069            let store = ThreadMetadataStore::global(cx).read(cx);
4070            let entry = store.entry(thread_id).unwrap();
4071            assert!(
4072                !entry.folder_paths().is_empty(),
4073                "thread should have folder paths before archiving"
4074            );
4075            (
4076                entry.folder_paths().clone(),
4077                entry.main_worktree_paths().clone(),
4078            )
4079        });
4080
4081        // Archive the thread.
4082        cx.update(|cx| {
4083            ThreadMetadataStore::global(cx).update(cx, |store, cx| {
4084                store.archive(thread_id, None, cx);
4085            });
4086        });
4087        cx.run_until_parked();
4088
4089        // Remove the worktree from the project, simulating what the
4090        // archive flow does for linked git worktrees.
4091        let worktree_id = cx.update(|cx| {
4092            project
4093                .read(cx)
4094                .visible_worktrees(cx)
4095                .next()
4096                .unwrap()
4097                .read(cx)
4098                .id()
4099        });
4100        project.update(cx, |project, cx| {
4101            project.remove_worktree(worktree_id, cx);
4102        });
4103        cx.run_until_parked();
4104
4105        // Trigger a thread event after archiving + worktree removal.
4106        // In production this happens when an async title-generation task
4107        // completes after the thread was archived.
4108        thread.update_in(&mut vcx, |thread, _window, cx| {
4109            thread.set_title("Generated title".into(), cx).detach();
4110        });
4111        vcx.run_until_parked();
4112
4113        // The archived thread must still have its original folder paths.
4114        cx.read(|cx| {
4115            let store = ThreadMetadataStore::global(cx).read(cx);
4116            let entry = store.entry(thread_id).unwrap();
4117            assert!(entry.archived, "thread should still be archived");
4118            assert_eq!(
4119                entry.display_title().as_ref(),
4120                "Generated title",
4121                "title should still be updated for archived threads"
4122            );
4123            assert_eq!(
4124                entry.folder_paths(),
4125                &folder_paths_before,
4126                "archived thread must retain its folder paths after worktree \
4127                 removal + subsequent thread event, otherwise restoring it \
4128                 will prompt the user to re-associate a project"
4129            );
4130            assert_eq!(
4131                entry.main_worktree_paths(),
4132                &main_paths_before,
4133                "archived thread must retain its main worktree paths after \
4134                 worktree removal + subsequent thread event"
4135            );
4136        });
4137    }
4138
4139    #[gpui::test]
4140    async fn test_collab_guest_threads_not_saved_to_metadata_store(cx: &mut TestAppContext) {
4141        init_test(cx);
4142
4143        let fs = FakeFs::new(cx.executor());
4144        let project = Project::test(fs, [Path::new("/project-a")], cx).await;
4145
4146        let (panel, mut vcx) = setup_panel_with_project(project.clone(), cx);
4147        crate::test_support::open_thread_with_connection(
4148            &panel,
4149            StubAgentConnection::new(),
4150            &mut vcx,
4151        );
4152        let thread = panel.read_with(&vcx, |panel, cx| panel.active_agent_thread(cx).unwrap());
4153        let thread_id = crate::test_support::active_thread_id(&panel, &vcx);
4154        thread.update_in(&mut vcx, |thread, _window, cx| {
4155            thread.push_user_content_block(None, "hello".into(), cx);
4156            thread.set_title("Thread".into(), cx).detach();
4157        });
4158        vcx.run_until_parked();
4159
4160        // Confirm the thread is in the store while the project is local.
4161        cx.update(|cx| {
4162            let store = ThreadMetadataStore::global(cx);
4163            assert!(
4164                store.read(cx).entry(thread_id).is_some(),
4165                "thread must be in the store while the project is local"
4166            );
4167        });
4168
4169        cx.update(|cx| {
4170            let store = ThreadMetadataStore::global(cx);
4171            store.update(cx, |store, cx| {
4172                store.delete(thread_id, cx);
4173            });
4174        });
4175        project.update(cx, |project, _cx| {
4176            project.mark_as_collab_for_testing();
4177        });
4178
4179        thread.update_in(&mut vcx, |thread, _window, cx| {
4180            thread.push_user_content_block(None, "more content".into(), cx);
4181        });
4182        vcx.run_until_parked();
4183
4184        cx.update(|cx| {
4185            let store = ThreadMetadataStore::global(cx);
4186            assert!(
4187                store.read(cx).entry(thread_id).is_none(),
4188                "threads must not be persisted while the project is a collab guest session"
4189            );
4190        });
4191    }
4192
4193    // When a worktree is added to a collab project, update_thread_work_dirs
4194    // fires with the new worktree paths. Without an is_via_collab() guard it
4195    // overwrites the stored paths of any retained or active local threads with
4196    // the new (expanded) path set, corrupting metadata that belonged to the
4197    // guest's own local project.
4198    #[gpui::test]
4199    async fn test_collab_guest_retained_thread_paths_not_overwritten_on_worktree_change(
4200        cx: &mut TestAppContext,
4201    ) {
4202        init_test(cx);
4203
4204        let fs = FakeFs::new(cx.executor());
4205        fs.insert_tree("/project-a", serde_json::json!({})).await;
4206        fs.insert_tree("/project-b", serde_json::json!({})).await;
4207        let project = Project::test(fs, [Path::new("/project-a")], cx).await;
4208
4209        let (panel, mut vcx) = setup_panel_with_project(project.clone(), cx);
4210
4211        // Open thread A and give it content so its metadata is saved with /project-a.
4212        crate::test_support::open_thread_with_connection(
4213            &panel,
4214            StubAgentConnection::new(),
4215            &mut vcx,
4216        );
4217        let thread_a_id = crate::test_support::active_thread_id(&panel, &vcx);
4218        let thread_a = panel.read_with(&vcx, |panel, cx| panel.active_agent_thread(cx).unwrap());
4219        thread_a.update_in(&mut vcx, |thread, _window, cx| {
4220            thread.push_user_content_block(None, "hello".into(), cx);
4221            thread.set_title("Thread A".into(), cx).detach();
4222        });
4223        vcx.run_until_parked();
4224
4225        cx.update(|cx| {
4226            let store = ThreadMetadataStore::global(cx);
4227            let entry = store.read(cx).entry(thread_a_id).unwrap();
4228            assert_eq!(
4229                entry.folder_paths().paths(),
4230                &[std::path::PathBuf::from("/project-a")],
4231                "thread A must be saved with /project-a before collab"
4232            );
4233        });
4234
4235        // Open thread B, making thread A a retained thread in the panel.
4236        crate::test_support::open_thread_with_connection(
4237            &panel,
4238            StubAgentConnection::new(),
4239            &mut vcx,
4240        );
4241        vcx.run_until_parked();
4242
4243        // Transition the project into collab mode (simulates joining as a guest).
4244        project.update(cx, |project, _cx| {
4245            project.mark_as_collab_for_testing();
4246        });
4247
4248        // Add a second worktree. For a real collab guest this would be one of
4249        // the host's worktrees arriving via the collab protocol, but here we
4250        // use a local path because the test infrastructure cannot easily produce
4251        // a remote worktree with a fully-scanned root entry.
4252        //
4253        // This fires WorktreeAdded → update_thread_work_dirs. Without an
4254        // is_via_collab() guard that call overwrites the stored paths of
4255        // retained thread A from {/project-a} to {/project-a, /project-b},
4256        // polluting its metadata with a path it never belonged to.
4257        project
4258            .update(cx, |project, cx| {
4259                project.find_or_create_worktree(Path::new("/project-b"), true, cx)
4260            })
4261            .await
4262            .unwrap();
4263        vcx.run_until_parked();
4264
4265        cx.update(|cx| {
4266            let store = ThreadMetadataStore::global(cx);
4267            let entry = store
4268                .read(cx)
4269                .entry(thread_a_id)
4270                .expect("thread A must still exist in the store");
4271            assert_eq!(
4272                entry.folder_paths().paths(),
4273                &[std::path::PathBuf::from("/project-a")],
4274                "retained thread A's stored path must not be updated while the project is via collab"
4275            );
4276        });
4277    }
4278}
4279
Served at tenant.openagents/omega Member data and write actions are omitted.