Collect main worktree paths on threads, and query them in the sidebar (#52945)

Mikayla Maki created

This PR has us collecting the main worktree path metadata for the
sidebar, allows us to query by that path, and in the interim adds in
logic for querying the main worktree paths. This means that, as this
change percolates, there shouldn't be much difference between opening a
workspace to a linked worktree, and opening a workspace to the main
worktree.

Self-Review Checklist:

- [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_import.rs         |   3 
crates/agent_ui/src/thread_metadata_store.rs | 126 ++++++++++++++++--
crates/sidebar/src/sidebar.rs                |  45 ++++++
crates/sidebar/src/sidebar_tests.rs          | 145 +++++++++++++++++++++
4 files changed, 302 insertions(+), 17 deletions(-)

Detailed changes

crates/agent_ui/src/thread_import.rs 🔗

@@ -17,7 +17,7 @@ use ui::{
     prelude::*,
 };
 use util::ResultExt;
-use workspace::{ModalView, MultiWorkspace, Workspace};
+use workspace::{ModalView, MultiWorkspace, PathList, Workspace};
 
 use crate::{
     Agent, AgentPanel,
@@ -500,6 +500,7 @@ fn collect_importable_threads(
                 updated_at: session.updated_at.unwrap_or_else(|| Utc::now()),
                 created_at: session.created_at,
                 folder_paths,
+                main_worktree_paths: PathList::default(),
                 archived: true,
             });
         }

crates/agent_ui/src/thread_metadata_store.rs 🔗

@@ -66,6 +66,7 @@ fn migrate_thread_metadata(cx: &mut App) {
                         updated_at: entry.updated_at,
                         created_at: entry.created_at,
                         folder_paths: entry.folder_paths,
+                        main_worktree_paths: PathList::default(),
                         archived: true,
                     })
                 })
@@ -126,6 +127,7 @@ pub struct ThreadMetadata {
     pub updated_at: DateTime<Utc>,
     pub created_at: Option<DateTime<Utc>>,
     pub folder_paths: PathList,
+    pub main_worktree_paths: PathList,
     pub archived: bool,
 }
 
@@ -149,6 +151,7 @@ pub struct ThreadMetadataStore {
     db: ThreadMetadataDb,
     threads: HashMap<acp::SessionId, ThreadMetadata>,
     threads_by_paths: HashMap<PathList, HashSet<acp::SessionId>>,
+    threads_by_main_paths: HashMap<PathList, HashSet<acp::SessionId>>,
     reload_task: Option<Shared<Task<()>>>,
     session_subscriptions: HashMap<acp::SessionId, Subscription>,
     pending_thread_ops_tx: smol::channel::Sender<DbOperation>,
@@ -238,6 +241,21 @@ impl ThreadMetadataStore {
             .filter(|s| !s.archived)
     }
 
+    /// Returns threads whose `main_worktree_paths` matches the given path list,
+    /// excluding archived threads. This finds threads that were opened in a
+    /// linked worktree but are associated with the given main worktree.
+    pub fn entries_for_main_worktree_path(
+        &self,
+        path_list: &PathList,
+    ) -> impl Iterator<Item = &ThreadMetadata> + '_ {
+        self.threads_by_main_paths
+            .get(path_list)
+            .into_iter()
+            .flatten()
+            .filter_map(|s| self.threads.get(s))
+            .filter(|s| !s.archived)
+    }
+
     fn reload(&mut self, cx: &mut Context<Self>) -> Shared<Task<()>> {
         let db = self.db.clone();
         self.reload_task.take();
@@ -254,12 +272,19 @@ impl ThreadMetadataStore {
                 this.update(cx, |this, cx| {
                     this.threads.clear();
                     this.threads_by_paths.clear();
+                    this.threads_by_main_paths.clear();
 
                     for row in rows {
                         this.threads_by_paths
                             .entry(row.folder_paths.clone())
                             .or_default()
                             .insert(row.session_id.clone());
+                        if !row.main_worktree_paths.is_empty() {
+                            this.threads_by_main_paths
+                                .entry(row.main_worktree_paths.clone())
+                                .or_default()
+                                .insert(row.session_id.clone());
+                        }
                         this.threads.insert(row.session_id.clone(), row);
                     }
 
@@ -298,12 +323,22 @@ impl ThreadMetadataStore {
     }
 
     fn save_internal(&mut self, metadata: ThreadMetadata) {
-        // If the folder paths have changed, we need to clear the old entry
-        if let Some(thread) = self.threads.get(&metadata.session_id)
-            && thread.folder_paths != metadata.folder_paths
-            && let Some(session_ids) = self.threads_by_paths.get_mut(&thread.folder_paths)
-        {
-            session_ids.remove(&metadata.session_id);
+        if let Some(thread) = self.threads.get(&metadata.session_id) {
+            if thread.folder_paths != metadata.folder_paths {
+                if let Some(session_ids) = self.threads_by_paths.get_mut(&thread.folder_paths) {
+                    session_ids.remove(&metadata.session_id);
+                }
+            }
+            if thread.main_worktree_paths != metadata.main_worktree_paths
+                && !thread.main_worktree_paths.is_empty()
+            {
+                if let Some(session_ids) = self
+                    .threads_by_main_paths
+                    .get_mut(&thread.main_worktree_paths)
+                {
+                    session_ids.remove(&metadata.session_id);
+                }
+            }
         }
 
         self.threads
@@ -314,6 +349,13 @@ impl ThreadMetadataStore {
             .or_default()
             .insert(metadata.session_id.clone());
 
+        if !metadata.main_worktree_paths.is_empty() {
+            self.threads_by_main_paths
+                .entry(metadata.main_worktree_paths.clone())
+                .or_default()
+                .insert(metadata.session_id.clone());
+        }
+
         self.pending_thread_ops_tx
             .try_send(DbOperation::Upsert(metadata))
             .log_err();
@@ -370,10 +412,18 @@ impl ThreadMetadataStore {
             return;
         }
 
-        if let Some(thread) = self.threads.get(&session_id)
-            && let Some(session_ids) = self.threads_by_paths.get_mut(&thread.folder_paths)
-        {
-            session_ids.remove(&session_id);
+        if let Some(thread) = self.threads.get(&session_id) {
+            if let Some(session_ids) = self.threads_by_paths.get_mut(&thread.folder_paths) {
+                session_ids.remove(&session_id);
+            }
+            if !thread.main_worktree_paths.is_empty() {
+                if let Some(session_ids) = self
+                    .threads_by_main_paths
+                    .get_mut(&thread.main_worktree_paths)
+                {
+                    session_ids.remove(&session_id);
+                }
+            }
         }
         self.threads.remove(&session_id);
         self.pending_thread_ops_tx
@@ -449,6 +499,7 @@ impl ThreadMetadataStore {
             db,
             threads: HashMap::default(),
             threads_by_paths: HashMap::default(),
+            threads_by_main_paths: HashMap::default(),
             reload_task: None,
             session_subscriptions: HashMap::default(),
             pending_thread_ops_tx: tx,
@@ -517,6 +568,20 @@ impl ThreadMetadataStore {
                     PathList::new(&paths)
                 };
 
+                let main_worktree_paths = {
+                    let project = thread_ref.project().read(cx);
+                    let mut main_paths: Vec<Arc<Path>> = Vec::new();
+                    for repo in project.repositories(cx).values() {
+                        let snapshot = repo.read(cx).snapshot();
+                        if snapshot.is_linked_worktree() {
+                            main_paths.push(snapshot.original_repo_abs_path.clone());
+                        }
+                    }
+                    main_paths.sort();
+                    main_paths.dedup();
+                    PathList::new(&main_paths)
+                };
+
                 // Threads without a folder path (e.g. started in an empty
                 // window) are archived by default so they don't get lost,
                 // because they won't show up in the sidebar. Users can reload
@@ -532,6 +597,7 @@ impl ThreadMetadataStore {
                     created_at: Some(created_at),
                     updated_at,
                     folder_paths,
+                    main_worktree_paths,
                     archived,
                 };
 
@@ -567,6 +633,8 @@ impl Domain for ThreadMetadataDb {
             ) STRICT;
         ),
         sql!(ALTER TABLE sidebar_threads ADD COLUMN archived INTEGER DEFAULT 0),
+        sql!(ALTER TABLE sidebar_threads ADD COLUMN main_worktree_paths TEXT),
+        sql!(ALTER TABLE sidebar_threads ADD COLUMN main_worktree_paths_order TEXT),
     ];
 }
 
@@ -583,7 +651,7 @@ impl ThreadMetadataDb {
     /// List all sidebar thread metadata, ordered by updated_at descending.
     pub fn list(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
         self.select::<ThreadMetadata>(
-            "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived \
+            "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived, main_worktree_paths, main_worktree_paths_order \
              FROM sidebar_threads \
              ORDER BY updated_at DESC"
         )?()
@@ -606,11 +674,18 @@ impl ThreadMetadataDb {
         } else {
             (Some(serialized.paths), Some(serialized.order))
         };
+        let main_serialized = row.main_worktree_paths.serialize();
+        let (main_worktree_paths, main_worktree_paths_order) = if row.main_worktree_paths.is_empty()
+        {
+            (None, None)
+        } else {
+            (Some(main_serialized.paths), Some(main_serialized.order))
+        };
         let archived = row.archived;
 
         self.write(move |conn| {
-            let sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived) \
-                       VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
+            let sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived, main_worktree_paths, main_worktree_paths_order) \
+                       VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \
                        ON CONFLICT(session_id) DO UPDATE SET \
                            agent_id = excluded.agent_id, \
                            title = excluded.title, \
@@ -618,7 +693,9 @@ impl ThreadMetadataDb {
                            created_at = excluded.created_at, \
                            folder_paths = excluded.folder_paths, \
                            folder_paths_order = excluded.folder_paths_order, \
-                           archived = excluded.archived";
+                           archived = excluded.archived, \
+                           main_worktree_paths = excluded.main_worktree_paths, \
+                           main_worktree_paths_order = excluded.main_worktree_paths_order";
             let mut stmt = Statement::prepare(conn, sql)?;
             let mut i = stmt.bind(&id, 1)?;
             i = stmt.bind(&agent_id, i)?;
@@ -627,7 +704,9 @@ impl ThreadMetadataDb {
             i = stmt.bind(&created_at, i)?;
             i = stmt.bind(&folder_paths, i)?;
             i = stmt.bind(&folder_paths_order, i)?;
-            stmt.bind(&archived, i)?;
+            i = stmt.bind(&archived, i)?;
+            i = stmt.bind(&main_worktree_paths, i)?;
+            stmt.bind(&main_worktree_paths_order, i)?;
             stmt.exec()
         })
         .await
@@ -657,6 +736,10 @@ impl Column for ThreadMetadata {
         let (folder_paths_order_str, next): (Option<String>, i32) =
             Column::column(statement, next)?;
         let (archived, next): (bool, i32) = Column::column(statement, next)?;
+        let (main_worktree_paths_str, next): (Option<String>, i32) =
+            Column::column(statement, next)?;
+        let (main_worktree_paths_order_str, next): (Option<String>, i32) =
+            Column::column(statement, next)?;
 
         let agent_id = agent_id
             .map(|id| AgentId::new(id))
@@ -678,6 +761,15 @@ impl Column for ThreadMetadata {
             })
             .unwrap_or_default();
 
+        let main_worktree_paths = main_worktree_paths_str
+            .map(|paths| {
+                PathList::deserialize(&util::path_list::SerializedPathList {
+                    paths,
+                    order: main_worktree_paths_order_str.unwrap_or_default(),
+                })
+            })
+            .unwrap_or_default();
+
         Ok((
             ThreadMetadata {
                 session_id: acp::SessionId::new(id),
@@ -686,6 +778,7 @@ impl Column for ThreadMetadata {
                 updated_at,
                 created_at,
                 folder_paths,
+                main_worktree_paths,
                 archived,
             },
             next,
@@ -742,6 +835,7 @@ mod tests {
             updated_at,
             created_at: Some(updated_at),
             folder_paths,
+            main_worktree_paths: PathList::default(),
         }
     }
 
@@ -957,6 +1051,7 @@ mod tests {
             updated_at: now - chrono::Duration::seconds(10),
             created_at: Some(now - chrono::Duration::seconds(10)),
             folder_paths: project_a_paths.clone(),
+            main_worktree_paths: PathList::default(),
             archived: false,
         };
 
@@ -1066,6 +1161,7 @@ mod tests {
             updated_at: existing_updated_at,
             created_at: Some(existing_updated_at),
             folder_paths: project_paths.clone(),
+            main_worktree_paths: PathList::default(),
             archived: false,
         };
 

crates/sidebar/src/sidebar.rs 🔗

@@ -906,6 +906,51 @@ impl Sidebar {
                     }
                 }
 
+                // Load threads from main worktrees when a workspace in this
+                // group is itself a linked worktree checkout.
+                let main_repo_queries: Vec<PathList> = group
+                    .workspaces
+                    .iter()
+                    .flat_map(|ws| root_repository_snapshots(ws, cx))
+                    .filter(|snapshot| snapshot.is_linked_worktree())
+                    .map(|snapshot| {
+                        PathList::new(std::slice::from_ref(&snapshot.original_repo_abs_path))
+                    })
+                    .collect();
+
+                for main_repo_path_list in main_repo_queries {
+                    let folder_path_matches = thread_store
+                        .read(cx)
+                        .entries_for_path(&main_repo_path_list)
+                        .cloned();
+                    let main_worktree_path_matches = thread_store
+                        .read(cx)
+                        .entries_for_main_worktree_path(&main_repo_path_list)
+                        .cloned();
+
+                    for row in folder_path_matches.chain(main_worktree_path_matches) {
+                        if !seen_session_ids.insert(row.session_id.clone()) {
+                            continue;
+                        }
+                        let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
+                        let worktrees =
+                            worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
+                        threads.push(ThreadEntry {
+                            metadata: row,
+                            icon,
+                            icon_from_external_svg,
+                            status: AgentThreadStatus::default(),
+                            workspace: ThreadEntryWorkspace::Closed(main_repo_path_list.clone()),
+                            is_live: false,
+                            is_background: false,
+                            is_title_generating: false,
+                            highlight_positions: Vec::new(),
+                            worktrees,
+                            diff_stats: DiffStats::default(),
+                        });
+                    }
+                }
+
                 // Build a lookup from live_infos and compute running/waiting
                 // counts in a single pass.
                 let mut live_info_by_session: HashMap<&acp::SessionId, &ActiveThreadInfo> =

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -149,6 +149,7 @@ fn save_thread_metadata(
         updated_at,
         created_at,
         folder_paths: path_list,
+        main_worktree_paths: PathList::default(),
         archived: false,
     };
     cx.update(|cx| {
@@ -697,6 +698,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                     session_id: acp::SessionId::new(Arc::from("t-1")),
                     agent_id: AgentId::new("zed-agent"),
                     folder_paths: PathList::default(),
+                    main_worktree_paths: PathList::default(),
                     title: "Completed thread".into(),
                     updated_at: Utc::now(),
                     created_at: Some(Utc::now()),
@@ -719,6 +721,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                     session_id: acp::SessionId::new(Arc::from("t-2")),
                     agent_id: AgentId::new("zed-agent"),
                     folder_paths: PathList::default(),
+                    main_worktree_paths: PathList::default(),
                     title: "Running thread".into(),
                     updated_at: Utc::now(),
                     created_at: Some(Utc::now()),
@@ -741,6 +744,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                     session_id: acp::SessionId::new(Arc::from("t-3")),
                     agent_id: AgentId::new("zed-agent"),
                     folder_paths: PathList::default(),
+                    main_worktree_paths: PathList::default(),
                     title: "Error thread".into(),
                     updated_at: Utc::now(),
                     created_at: Some(Utc::now()),
@@ -763,6 +767,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                     session_id: acp::SessionId::new(Arc::from("t-4")),
                     agent_id: AgentId::new("zed-agent"),
                     folder_paths: PathList::default(),
+                    main_worktree_paths: PathList::default(),
                     title: "Waiting thread".into(),
                     updated_at: Utc::now(),
                     created_at: Some(Utc::now()),
@@ -785,6 +790,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                     session_id: acp::SessionId::new(Arc::from("t-5")),
                     agent_id: AgentId::new("zed-agent"),
                     folder_paths: PathList::default(),
+                    main_worktree_paths: PathList::default(),
                     title: "Notified thread".into(),
                     updated_at: Utc::now(),
                     created_at: Some(Utc::now()),
@@ -2052,6 +2058,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
                 updated_at: Utc::now(),
                 created_at: None,
                 folder_paths: PathList::default(),
+                main_worktree_paths: PathList::default(),
                 archived: false,
             },
             &workspace_a,
@@ -2107,6 +2114,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
                 updated_at: Utc::now(),
                 created_at: None,
                 folder_paths: PathList::default(),
+                main_worktree_paths: PathList::default(),
                 archived: false,
             },
             &workspace_b,
@@ -3571,6 +3579,7 @@ async fn test_activate_archived_thread_with_saved_paths_activates_matching_works
                 updated_at: Utc::now(),
                 created_at: None,
                 folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
+                main_worktree_paths: PathList::default(),
                 archived: false,
             },
             window,
@@ -3633,6 +3642,7 @@ async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
                 updated_at: Utc::now(),
                 created_at: None,
                 folder_paths: PathList::new(&[std::path::PathBuf::from("/project-b")]),
+                main_worktree_paths: PathList::default(),
                 archived: false,
             },
             window,
@@ -3695,6 +3705,7 @@ async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
                 updated_at: Utc::now(),
                 created_at: None,
                 folder_paths: PathList::default(),
+                main_worktree_paths: PathList::default(),
                 archived: false,
             },
             window,
@@ -3749,6 +3760,7 @@ async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut
                 updated_at: Utc::now(),
                 created_at: None,
                 folder_paths: path_list_b,
+                main_worktree_paths: PathList::default(),
                 archived: false,
             },
             window,
@@ -3798,6 +3810,7 @@ async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &m
                 updated_at: Utc::now(),
                 created_at: None,
                 folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
+                main_worktree_paths: PathList::default(),
                 archived: false,
             },
             window,
@@ -3874,6 +3887,7 @@ async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_t
                 updated_at: Utc::now(),
                 created_at: None,
                 folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
+                main_worktree_paths: PathList::default(),
                 archived: false,
             },
             window,
@@ -3949,6 +3963,7 @@ async fn test_activate_archived_thread_prefers_current_window_for_matching_paths
                 updated_at: Utc::now(),
                 created_at: None,
                 folder_paths: PathList::new(&[PathBuf::from("/project-a")]),
+                main_worktree_paths: PathList::default(),
                 archived: false,
             },
             window,
@@ -4688,12 +4703,96 @@ async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppCon
     });
 }
 
+#[gpui::test]
+async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
+    // When only a linked worktree workspace is open (not the main repo),
+    // threads saved against the main repo should still appear in the sidebar.
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+
+    // Create the main repo with a linked worktree.
+    fs.insert_tree(
+        "/project",
+        serde_json::json!({
+            ".git": {
+                "worktrees": {
+                    "feature-a": {
+                        "commondir": "../../",
+                        "HEAD": "ref: refs/heads/feature-a",
+                    },
+                },
+            },
+            "src": {},
+        }),
+    )
+    .await;
+
+    fs.insert_tree(
+        "/wt-feature-a",
+        serde_json::json!({
+            ".git": "gitdir: /project/.git/worktrees/feature-a",
+            "src": {},
+        }),
+    )
+    .await;
+
+    fs.add_linked_worktree_for_repo(
+        std::path::Path::new("/project/.git"),
+        false,
+        git::repository::Worktree {
+            path: std::path::PathBuf::from("/wt-feature-a"),
+            ref_name: Some("refs/heads/feature-a".into()),
+            sha: "abc".into(),
+            is_main: false,
+        },
+    )
+    .await;
+
+    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+    // Only open the linked worktree as a workspace — NOT the main repo.
+    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
+    worktree_project
+        .update(cx, |p, cx| p.git_scans_complete(cx))
+        .await;
+
+    let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
+        MultiWorkspace::test_new(worktree_project.clone(), window, cx)
+    });
+    let sidebar = setup_sidebar(&multi_workspace, cx);
+
+    // Save a thread against the MAIN repo path.
+    let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]);
+    save_named_thread_metadata("main-thread", "Main Repo Thread", &main_paths, cx).await;
+
+    // Save a thread against the linked worktree path.
+    let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
+    save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await;
+
+    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+    cx.run_until_parked();
+
+    // Both threads should be visible: the worktree thread by direct lookup,
+    // and the main repo thread because the workspace is a linked worktree
+    // and we also query the main repo path.
+    let entries = visible_entries_as_strings(&sidebar, cx);
+    assert!(
+        entries.iter().any(|e| e.contains("Main Repo Thread")),
+        "expected main repo thread to be visible in linked worktree workspace, got: {entries:?}"
+    );
+    assert!(
+        entries.iter().any(|e| e.contains("Worktree Thread")),
+        "expected worktree thread to be visible, got: {entries:?}"
+    );
+}
+
 mod property_test {
     use super::*;
     use gpui::EntityId;
 
     struct UnopenedWorktree {
         path: String,
+        main_workspace_path: String,
     }
 
     struct TestState {
@@ -4834,6 +4933,34 @@ mod property_test {
         save_thread_metadata(session_id, title, updated_at, None, path_list, cx);
     }
 
+    fn save_thread_to_path_with_main(
+        state: &mut TestState,
+        path_list: PathList,
+        main_worktree_paths: PathList,
+        cx: &mut gpui::VisualTestContext,
+    ) {
+        let session_id = state.next_thread_id();
+        let title: SharedString = format!("Thread {}", session_id).into();
+        let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
+            .unwrap()
+            + chrono::Duration::seconds(state.thread_counter as i64);
+        let metadata = ThreadMetadata {
+            session_id,
+            agent_id: agent::ZED_AGENT_ID.clone(),
+            title,
+            updated_at,
+            created_at: None,
+            folder_paths: path_list,
+            main_worktree_paths,
+            archived: false,
+        };
+        cx.update(|_, cx| {
+            ThreadMetadataStore::global(cx)
+                .update(cx, |store, cx| store.save_manually(metadata, cx))
+        });
+        cx.run_until_parked();
+    }
+
     async fn perform_operation(
         operation: Operation,
         state: &mut TestState,
@@ -4852,7 +4979,9 @@ mod property_test {
             Operation::SaveWorktreeThread { worktree_index } => {
                 let worktree = &state.unopened_worktrees[worktree_index];
                 let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
-                save_thread_to_path(state, path_list, cx);
+                let main_worktree_paths =
+                    PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
+                save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
             }
             Operation::DeleteThread { index } => {
                 let session_id = state.remove_thread(index);
@@ -5004,6 +5133,7 @@ mod property_test {
 
                 state.unopened_worktrees.push(UnopenedWorktree {
                     path: worktree_path,
+                    main_workspace_path: main_path.clone(),
                 });
             }
         }
@@ -5108,6 +5238,19 @@ mod property_test {
                         metadata_thread_ids.insert(metadata.session_id.clone());
                     }
                 }
+                if snapshot.is_linked_worktree() {
+                    let main_path_list =
+                        PathList::new(std::slice::from_ref(&snapshot.original_repo_abs_path));
+                    for metadata in thread_store.read(cx).entries_for_path(&main_path_list) {
+                        metadata_thread_ids.insert(metadata.session_id.clone());
+                    }
+                    for metadata in thread_store
+                        .read(cx)
+                        .entries_for_main_worktree_path(&main_path_list)
+                    {
+                        metadata_thread_ids.insert(metadata.session_id.clone());
+                    }
+                }
             }
         }