Skip to repository content4279 lines · 157.4 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:55:03.939Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
thread_metadata_store.rs
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(), ®ular_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