sidebar: Only migrate some threads (#52018)

Bennet Bo Fenner created

## Context

We now only migrate up to 10 threads per project to the sidebar and also
ignore threads that do not have a workspace

## How to Review

<!-- Help reviewers focus their attention:
- For small PRs: note what to focus on (e.g., "error handling in
foo.rs")
- For large PRs (>400 LOC): provide a guided tour — numbered list of
files/commits to read in order. (The `large-pr` label is applied
automatically.)
     - See the review process guidelines for comment conventions -->

## Self-Review Checklist

<!-- Check before requesting review: -->
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A

Change summary

crates/agent_ui/src/thread_metadata_store.rs | 173 +++++++++++++++++----
1 file changed, 136 insertions(+), 37 deletions(-)

Detailed changes

crates/agent_ui/src/thread_metadata_store.rs 🔗

@@ -36,9 +36,12 @@ pub fn init(cx: &mut App) {
 }
 
 /// Migrate existing thread metadata from native agent thread store to the new metadata storage.
+/// We migrate the last 10 threads per project and skip threads that do not have a project.
 ///
 /// TODO: Remove this after N weeks of shipping the sidebar
 fn migrate_thread_metadata(cx: &mut App) {
+    const MAX_MIGRATED_THREADS_PER_PROJECT: usize = 10;
+
     let store = SidebarThreadMetadataStore::global(cx);
     let db = store.read(cx).db.clone();
 
@@ -48,16 +51,32 @@ fn migrate_thread_metadata(cx: &mut App) {
         }
 
         let metadata = store.read_with(cx, |_store, app| {
+            let mut migrated_threads_per_project = HashMap::default();
+
             ThreadStore::global(app)
                 .read(app)
                 .entries()
-                .map(|entry| ThreadMetadata {
-                    session_id: entry.id,
-                    agent_id: None,
-                    title: entry.title,
-                    updated_at: entry.updated_at,
-                    created_at: entry.created_at,
-                    folder_paths: entry.folder_paths,
+                .filter_map(|entry| {
+                    if entry.folder_paths.is_empty() {
+                        return None;
+                    }
+
+                    let migrated_thread_count = migrated_threads_per_project
+                        .entry(entry.folder_paths.clone())
+                        .or_insert(0);
+                    if *migrated_thread_count >= MAX_MIGRATED_THREADS_PER_PROJECT {
+                        return None;
+                    }
+                    *migrated_thread_count += 1;
+
+                    Some(ThreadMetadata {
+                        session_id: entry.id,
+                        agent_id: None,
+                        title: entry.title,
+                        updated_at: entry.updated_at,
+                        created_at: entry.created_at,
+                        folder_paths: entry.folder_paths,
+                    })
                 })
                 .collect::<Vec<_>>()
         });
@@ -730,35 +749,68 @@ mod tests {
         });
         assert_eq!(list.len(), 0);
 
+        let project_a_paths = PathList::new(&[Path::new("/project-a")]);
+        let project_b_paths = PathList::new(&[Path::new("/project-b")]);
         let now = Utc::now();
 
-        // Populate the native ThreadStore via save_thread
-        let save1 = cx.update(|cx| {
-            let thread_store = ThreadStore::global(cx);
-            thread_store.update(cx, |store, cx| {
-                store.save_thread(
-                    acp::SessionId::new("session-1"),
-                    make_db_thread("Thread 1", now),
-                    PathList::default(),
-                    cx,
-                )
-            })
-        });
-        save1.await.unwrap();
-        cx.run_until_parked();
+        for index in 0..12 {
+            let updated_at = now + chrono::Duration::seconds(index as i64);
+            let session_id = format!("project-a-session-{index}");
+            let title = format!("Project A Thread {index}");
+
+            let save_task = cx.update(|cx| {
+                let thread_store = ThreadStore::global(cx);
+                let session_id = session_id.clone();
+                let title = title.clone();
+                let project_a_paths = project_a_paths.clone();
+                thread_store.update(cx, |store, cx| {
+                    store.save_thread(
+                        acp::SessionId::new(session_id),
+                        make_db_thread(&title, updated_at),
+                        project_a_paths,
+                        cx,
+                    )
+                })
+            });
+            save_task.await.unwrap();
+            cx.run_until_parked();
+        }
+
+        for index in 0..3 {
+            let updated_at = now + chrono::Duration::seconds(100 + index as i64);
+            let session_id = format!("project-b-session-{index}");
+            let title = format!("Project B Thread {index}");
+
+            let save_task = cx.update(|cx| {
+                let thread_store = ThreadStore::global(cx);
+                let session_id = session_id.clone();
+                let title = title.clone();
+                let project_b_paths = project_b_paths.clone();
+                thread_store.update(cx, |store, cx| {
+                    store.save_thread(
+                        acp::SessionId::new(session_id),
+                        make_db_thread(&title, updated_at),
+                        project_b_paths,
+                        cx,
+                    )
+                })
+            });
+            save_task.await.unwrap();
+            cx.run_until_parked();
+        }
 
-        let save2 = cx.update(|cx| {
+        let save_projectless = cx.update(|cx| {
             let thread_store = ThreadStore::global(cx);
             thread_store.update(cx, |store, cx| {
                 store.save_thread(
-                    acp::SessionId::new("session-2"),
-                    make_db_thread("Thread 2", now),
+                    acp::SessionId::new("projectless-session"),
+                    make_db_thread("Projectless Thread", now + chrono::Duration::seconds(200)),
                     PathList::default(),
                     cx,
                 )
             })
         });
-        save2.await.unwrap();
+        save_projectless.await.unwrap();
         cx.run_until_parked();
 
         // Run migration
@@ -768,26 +820,73 @@ mod tests {
 
         cx.run_until_parked();
 
-        // Verify the metadata was migrated
+        // Verify the metadata was migrated, limited to 10 per project, and
+        // projectless threads were skipped.
         let list = cx.update(|cx| {
             let store = SidebarThreadMetadataStore::global(cx);
             store.read(cx).entries().collect::<Vec<_>>()
         });
-        assert_eq!(list.len(), 2);
+        assert_eq!(list.len(), 13);
 
-        let metadata1 = list
+        assert!(
+            list.iter()
+                .all(|metadata| !metadata.folder_paths.is_empty())
+        );
+        assert!(
+            list.iter()
+                .all(|metadata| metadata.session_id.0.as_ref() != "projectless-session")
+        );
+
+        let project_a_entries = list
             .iter()
-            .find(|m| m.session_id.0.as_ref() == "session-1")
-            .expect("session-1 should be in migrated metadata");
-        assert_eq!(metadata1.title.as_ref(), "Thread 1");
-        assert!(metadata1.agent_id.is_none());
+            .filter(|metadata| metadata.folder_paths == project_a_paths)
+            .collect::<Vec<_>>();
+        assert_eq!(project_a_entries.len(), 10);
+        assert_eq!(
+            project_a_entries
+                .iter()
+                .map(|metadata| metadata.session_id.0.as_ref())
+                .collect::<Vec<_>>(),
+            vec![
+                "project-a-session-11",
+                "project-a-session-10",
+                "project-a-session-9",
+                "project-a-session-8",
+                "project-a-session-7",
+                "project-a-session-6",
+                "project-a-session-5",
+                "project-a-session-4",
+                "project-a-session-3",
+                "project-a-session-2",
+            ]
+        );
+        assert!(
+            project_a_entries
+                .iter()
+                .all(|metadata| metadata.agent_id.is_none())
+        );
 
-        let metadata2 = list
+        let project_b_entries = list
             .iter()
-            .find(|m| m.session_id.0.as_ref() == "session-2")
-            .expect("session-2 should be in migrated metadata");
-        assert_eq!(metadata2.title.as_ref(), "Thread 2");
-        assert!(metadata2.agent_id.is_none());
+            .filter(|metadata| metadata.folder_paths == project_b_paths)
+            .collect::<Vec<_>>();
+        assert_eq!(project_b_entries.len(), 3);
+        assert_eq!(
+            project_b_entries
+                .iter()
+                .map(|metadata| metadata.session_id.0.as_ref())
+                .collect::<Vec<_>>(),
+            vec![
+                "project-b-session-2",
+                "project-b-session-1",
+                "project-b-session-0",
+            ]
+        );
+        assert!(
+            project_b_entries
+                .iter()
+                .all(|metadata| metadata.agent_id.is_none())
+        );
     }
 
     #[gpui::test]