agent: Support remote connection args in thread metadata database (#53550)

Anthony Eid and Eric Holk created

This PR adds remote connection data to the threads metadata database.
This fixes an issue where threads ran on separate projects with the same
remote/local path list would show up in the sidebar in both workspaces,
instead of only the workspace they were originally created in.

I added a migrator that uses the workspace persistence database to add
remote connection argument to threads that only have path list matches
with a remote project. If a path list matches with both local/remote
workspaces, we default to setting it as local.

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 or Added/Fixed/Improved ...

---------

Co-authored-by: Eric Holk <eric@zed.dev>

Change summary

crates/agent_ui/src/thread_import.rs         | 117 +++++++---
crates/agent_ui/src/thread_metadata_store.rs | 240 +++++++++++++++++++--
crates/sidebar/src/sidebar_tests.rs          |  20 +
3 files changed, 315 insertions(+), 62 deletions(-)

Detailed changes

crates/agent_ui/src/thread_import.rs 🔗

@@ -12,6 +12,7 @@ use gpui::{
 };
 use notifications::status_toast::{StatusToast, ToastIcon};
 use project::{AgentId, AgentRegistryStore, AgentServerStore};
+use remote::RemoteConnectionOptions;
 use ui::{
     Checkbox, KeyBinding, ListItem, ListItemSpacing, Modal, ModalFooter, ModalHeader, Section,
     prelude::*,
@@ -436,19 +437,28 @@ fn find_threads_to_import(
     let mut wait_for_connection_tasks = Vec::new();
 
     for store in stores {
+        let remote_connection = store
+            .read(cx)
+            .project()
+            .read(cx)
+            .remote_connection_options(cx);
+
         for agent_id in agent_ids.clone() {
             let agent = Agent::from(agent_id.clone());
             let server = agent.server(<dyn Fs>::global(cx), ThreadStore::global(cx));
             let entry = store.update(cx, |store, cx| store.request_connection(agent, server, cx));
-            wait_for_connection_tasks
-                .push(entry.read(cx).wait_for_connection().map(|s| (agent_id, s)));
+
+            wait_for_connection_tasks.push(entry.read(cx).wait_for_connection().map({
+                let remote_connection = remote_connection.clone();
+                move |state| (agent_id, remote_connection, state)
+            }));
         }
     }
 
     let mut session_list_tasks = Vec::new();
     cx.spawn(async move |cx| {
         let results = futures::future::join_all(wait_for_connection_tasks).await;
-        for (agent, result) in results {
+        for (agent_id, remote_connection, result) in results {
             let Some(state) = result.log_err() else {
                 continue;
             };
@@ -457,18 +467,25 @@ fn find_threads_to_import(
             };
             let task = cx.update(|cx| {
                 list.list_sessions(AgentSessionListRequest::default(), cx)
-                    .map(|r| (agent, r))
+                    .map({
+                        let remote_connection = remote_connection.clone();
+                        move |response| (agent_id, remote_connection, response)
+                    })
             });
             session_list_tasks.push(task);
         }
 
         let mut sessions_by_agent = Vec::new();
         let results = futures::future::join_all(session_list_tasks).await;
-        for (agent_id, result) in results {
+        for (agent_id, remote_connection, result) in results {
             let Some(response) = result.log_err() else {
                 continue;
             };
-            sessions_by_agent.push((agent_id, response.sessions));
+            sessions_by_agent.push(SessionByAgent {
+                agent_id,
+                remote_connection,
+                sessions: response.sessions,
+            });
         }
 
         Ok(collect_importable_threads(
@@ -478,12 +495,23 @@ fn find_threads_to_import(
     })
 }
 
+struct SessionByAgent {
+    agent_id: AgentId,
+    remote_connection: Option<RemoteConnectionOptions>,
+    sessions: Vec<acp_thread::AgentSessionInfo>,
+}
+
 fn collect_importable_threads(
-    sessions_by_agent: Vec<(AgentId, Vec<acp_thread::AgentSessionInfo>)>,
+    sessions_by_agent: Vec<SessionByAgent>,
     mut existing_sessions: HashSet<acp::SessionId>,
 ) -> Vec<ThreadMetadata> {
     let mut to_insert = Vec::new();
-    for (agent_id, sessions) in sessions_by_agent {
+    for SessionByAgent {
+        agent_id,
+        remote_connection,
+        sessions,
+    } in sessions_by_agent
+    {
         for session in sessions {
             if !existing_sessions.insert(session.session_id.clone()) {
                 continue;
@@ -501,6 +529,7 @@ fn collect_importable_threads(
                 created_at: session.created_at,
                 folder_paths,
                 main_worktree_paths: PathList::default(),
+                remote_connection: remote_connection.clone(),
                 archived: true,
             });
         }
@@ -538,9 +567,10 @@ mod tests {
         let existing = HashSet::from_iter(vec![acp::SessionId::new("existing-1")]);
         let paths = PathList::new(&[Path::new("/project")]);
 
-        let sessions_by_agent = vec![(
-            AgentId::new("agent-a"),
-            vec![
+        let sessions_by_agent = vec![SessionByAgent {
+            agent_id: AgentId::new("agent-a"),
+            remote_connection: None,
+            sessions: vec![
                 make_session(
                     "existing-1",
                     Some("Already There"),
@@ -550,7 +580,7 @@ mod tests {
                 ),
                 make_session("new-1", Some("Brand New"), Some(paths), None, None),
             ],
-        )];
+        }];
 
         let result = collect_importable_threads(sessions_by_agent, existing);
 
@@ -564,13 +594,14 @@ mod tests {
         let existing = HashSet::default();
         let paths = PathList::new(&[Path::new("/project")]);
 
-        let sessions_by_agent = vec![(
-            AgentId::new("agent-a"),
-            vec![
+        let sessions_by_agent = vec![SessionByAgent {
+            agent_id: AgentId::new("agent-a"),
+            remote_connection: None,
+            sessions: vec![
                 make_session("has-dirs", Some("With Dirs"), Some(paths), None, None),
                 make_session("no-dirs", Some("No Dirs"), None, None, None),
             ],
-        )];
+        }];
 
         let result = collect_importable_threads(sessions_by_agent, existing);
 
@@ -583,13 +614,14 @@ mod tests {
         let existing = HashSet::default();
         let paths = PathList::new(&[Path::new("/project")]);
 
-        let sessions_by_agent = vec![(
-            AgentId::new("agent-a"),
-            vec![
+        let sessions_by_agent = vec![SessionByAgent {
+            agent_id: AgentId::new("agent-a"),
+            remote_connection: None,
+            sessions: vec![
                 make_session("s1", Some("Thread 1"), Some(paths.clone()), None, None),
                 make_session("s2", Some("Thread 2"), Some(paths), None, None),
             ],
-        )];
+        }];
 
         let result = collect_importable_threads(sessions_by_agent, existing);
 
@@ -603,20 +635,22 @@ mod tests {
         let paths = PathList::new(&[Path::new("/project")]);
 
         let sessions_by_agent = vec![
-            (
-                AgentId::new("agent-a"),
-                vec![make_session(
+            SessionByAgent {
+                agent_id: AgentId::new("agent-a"),
+                remote_connection: None,
+                sessions: vec![make_session(
                     "s1",
                     Some("From A"),
                     Some(paths.clone()),
                     None,
                     None,
                 )],
-            ),
-            (
-                AgentId::new("agent-b"),
-                vec![make_session("s2", Some("From B"), Some(paths), None, None)],
-            ),
+            },
+            SessionByAgent {
+                agent_id: AgentId::new("agent-b"),
+                remote_connection: None,
+                sessions: vec![make_session("s2", Some("From B"), Some(paths), None, None)],
+            },
         ];
 
         let result = collect_importable_threads(sessions_by_agent, existing);
@@ -640,26 +674,28 @@ mod tests {
         let paths = PathList::new(&[Path::new("/project")]);
 
         let sessions_by_agent = vec![
-            (
-                AgentId::new("agent-a"),
-                vec![make_session(
+            SessionByAgent {
+                agent_id: AgentId::new("agent-a"),
+                remote_connection: None,
+                sessions: vec![make_session(
                     "shared-session",
                     Some("From A"),
                     Some(paths.clone()),
                     None,
                     None,
                 )],
-            ),
-            (
-                AgentId::new("agent-b"),
-                vec![make_session(
+            },
+            SessionByAgent {
+                agent_id: AgentId::new("agent-b"),
+                remote_connection: None,
+                sessions: vec![make_session(
                     "shared-session",
                     Some("From B"),
                     Some(paths),
                     None,
                     None,
                 )],
-            ),
+            },
         ];
 
         let result = collect_importable_threads(sessions_by_agent, existing);
@@ -679,13 +715,14 @@ mod tests {
         let existing =
             HashSet::from_iter(vec![acp::SessionId::new("s1"), acp::SessionId::new("s2")]);
 
-        let sessions_by_agent = vec![(
-            AgentId::new("agent-a"),
-            vec![
+        let sessions_by_agent = vec![SessionByAgent {
+            agent_id: AgentId::new("agent-a"),
+            remote_connection: None,
+            sessions: vec![
                 make_session("s1", Some("T1"), Some(paths.clone()), None, None),
                 make_session("s2", Some("T2"), Some(paths), None, None),
             ],
-        )];
+        }];
 
         let result = collect_importable_threads(sessions_by_agent, existing);
         assert!(result.is_empty());

crates/agent_ui/src/thread_metadata_store.rs 🔗

@@ -10,31 +10,37 @@ use anyhow::Context as _;
 use chrono::{DateTime, Utc};
 use collections::{HashMap, HashSet};
 use db::{
+    kvp::KeyValueStore,
     sqlez::{
         bindable::Column, domain::Domain, statement::Statement,
         thread_safe_connection::ThreadSafeConnection,
     },
     sqlez_macros::sql,
 };
-use futures::{FutureExt as _, future::Shared};
+use fs::Fs;
+use futures::{FutureExt, future::Shared};
 use gpui::{AppContext as _, Entity, Global, Subscription, Task};
 use project::AgentId;
+use remote::RemoteConnectionOptions;
 use ui::{App, Context, SharedString};
 use util::ResultExt as _;
-use workspace::PathList;
+use workspace::{PathList, SerializedWorkspaceLocation, WorkspaceDb};
 
 use crate::DEFAULT_THREAD_TITLE;
 
+const THREAD_REMOTE_CONNECTION_MIGRATION_KEY: &str = "thread-metadata-remote-connection-backfill";
+
 pub fn init(cx: &mut App) {
     ThreadMetadataStore::init_global(cx);
-    migrate_thread_metadata(cx);
+    let migration_task = migrate_thread_metadata(cx);
+    migrate_thread_remote_connections(cx, migration_task);
 }
 
 /// Migrate existing thread metadata from native agent thread store to the new metadata storage.
 /// We skip migrating threads that do not have a project.
 ///
 /// TODO: Remove this after N weeks of shipping the sidebar
-fn migrate_thread_metadata(cx: &mut App) {
+fn migrate_thread_metadata(cx: &mut App) -> Task<anyhow::Result<()>> {
     let store = ThreadMetadataStore::global(cx);
     let db = store.read(cx).db.clone();
 
@@ -60,6 +66,7 @@ fn migrate_thread_metadata(cx: &mut App) {
                         created_at: entry.created_at,
                         folder_paths: entry.folder_paths,
                         main_worktree_paths: PathList::default(),
+                        remote_connection: None,
                         archived: true,
                     })
                 })
@@ -104,6 +111,84 @@ fn migrate_thread_metadata(cx: &mut App) {
         let _ = store.update(cx, |store, cx| store.reload(cx));
         anyhow::Ok(())
     })
+}
+
+fn migrate_thread_remote_connections(cx: &mut App, migration_task: Task<anyhow::Result<()>>) {
+    let store = ThreadMetadataStore::global(cx);
+    let db = store.read(cx).db.clone();
+    let kvp = KeyValueStore::global(cx);
+    let workspace_db = WorkspaceDb::global(cx);
+    let fs = <dyn Fs>::global(cx);
+
+    cx.spawn(async move |cx| -> anyhow::Result<()> {
+        migration_task.await?;
+
+        if kvp
+            .read_kvp(THREAD_REMOTE_CONNECTION_MIGRATION_KEY)?
+            .is_some()
+        {
+            return Ok(());
+        }
+
+        let recent_workspaces = workspace_db.recent_workspaces_on_disk(fs.as_ref()).await?;
+
+        let mut local_path_lists = HashSet::<PathList>::default();
+        let mut remote_path_lists = HashMap::<PathList, RemoteConnectionOptions>::default();
+
+        recent_workspaces
+            .iter()
+            .filter(|(_, location, path_list, _)| {
+                !path_list.is_empty() && matches!(location, &SerializedWorkspaceLocation::Local)
+            })
+            .for_each(|(_, _, path_list, _)| {
+                local_path_lists.insert(path_list.clone());
+            });
+
+        for (_, location, path_list, _) in recent_workspaces {
+            match location {
+                SerializedWorkspaceLocation::Remote(remote_connection)
+                    if !local_path_lists.contains(&path_list) =>
+                {
+                    remote_path_lists
+                        .entry(path_list)
+                        .or_insert(remote_connection);
+                }
+                _ => {}
+            }
+        }
+
+        let mut reloaded = false;
+        for metadata in db.list()? {
+            if metadata.remote_connection.is_some() {
+                continue;
+            }
+
+            if let Some(remote_connection) = remote_path_lists
+                .get(&metadata.folder_paths)
+                .or_else(|| remote_path_lists.get(&metadata.main_worktree_paths))
+            {
+                db.save(ThreadMetadata {
+                    remote_connection: Some(remote_connection.clone()),
+                    ..metadata
+                })
+                .await?;
+                reloaded = true;
+            }
+        }
+
+        let reloaded_task = reloaded
+            .then_some(store.update(cx, |store, cx| store.reload(cx)))
+            .unwrap_or(Task::ready(()).shared());
+
+        kvp.write_kvp(
+            THREAD_REMOTE_CONNECTION_MIGRATION_KEY.to_string(),
+            "1".to_string(),
+        )
+        .await?;
+        reloaded_task.await;
+
+        Ok(())
+    })
     .detach_and_log_err(cx);
 }
 
@@ -121,6 +206,7 @@ pub struct ThreadMetadata {
     pub created_at: Option<DateTime<Utc>>,
     pub folder_paths: PathList,
     pub main_worktree_paths: PathList,
+    pub remote_connection: Option<RemoteConnectionOptions>,
     pub archived: bool,
 }
 
@@ -715,8 +801,8 @@ impl ThreadMetadataStore {
 
                 let agent_id = thread_ref.connection().agent_id();
 
+                let project = thread_ref.project().read(cx);
                 let folder_paths = {
-                    let project = thread_ref.project().read(cx);
                     let paths: Vec<Arc<Path>> = project
                         .visible_worktrees(cx)
                         .map(|worktree| worktree.read(cx).abs_path())
@@ -724,12 +810,9 @@ impl ThreadMetadataStore {
                     PathList::new(&paths)
                 };
 
-                let main_worktree_paths = thread_ref
-                    .project()
-                    .read(cx)
-                    .project_group_key(cx)
-                    .path_list()
-                    .clone();
+                let project_group_key = project.project_group_key(cx);
+                let main_worktree_paths = project_group_key.path_list().clone();
+                let remote_connection = project_group_key.host();
 
                 // Threads without a folder path (e.g. started in an empty
                 // window) are archived by default so they don't get lost,
@@ -747,6 +830,7 @@ impl ThreadMetadataStore {
                     updated_at,
                     folder_paths,
                     main_worktree_paths,
+                    remote_connection,
                     archived,
                 };
 
@@ -801,6 +885,7 @@ impl Domain for ThreadMetadataDb {
                 PRIMARY KEY (session_id, archived_worktree_id)
             ) STRICT;
         ),
+        sql!(ALTER TABLE sidebar_threads ADD COLUMN remote_connection TEXT),
     ];
 }
 
@@ -817,7 +902,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, main_worktree_paths, main_worktree_paths_order \
+            "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived, main_worktree_paths, main_worktree_paths_order, remote_connection \
              FROM sidebar_threads \
              ORDER BY updated_at DESC"
         )?()
@@ -847,11 +932,17 @@ impl ThreadMetadataDb {
         } else {
             (Some(main_serialized.paths), Some(main_serialized.order))
         };
+        let remote_connection = row
+            .remote_connection
+            .as_ref()
+            .map(serde_json::to_string)
+            .transpose()
+            .context("serialize thread metadata remote connection")?;
         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, main_worktree_paths, main_worktree_paths_order) \
-                       VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \
+            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, remote_connection) \
+                       VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) \
                        ON CONFLICT(session_id) DO UPDATE SET \
                            agent_id = excluded.agent_id, \
                            title = excluded.title, \
@@ -861,7 +952,8 @@ impl ThreadMetadataDb {
                            folder_paths_order = excluded.folder_paths_order, \
                            archived = excluded.archived, \
                            main_worktree_paths = excluded.main_worktree_paths, \
-                           main_worktree_paths_order = excluded.main_worktree_paths_order";
+                           main_worktree_paths_order = excluded.main_worktree_paths_order, \
+                           remote_connection = excluded.remote_connection";
             let mut stmt = Statement::prepare(conn, sql)?;
             let mut i = stmt.bind(&id, 1)?;
             i = stmt.bind(&agent_id, i)?;
@@ -872,7 +964,8 @@ impl ThreadMetadataDb {
             i = stmt.bind(&folder_paths_order, i)?;
             i = stmt.bind(&archived, i)?;
             i = stmt.bind(&main_worktree_paths, i)?;
-            stmt.bind(&main_worktree_paths_order, i)?;
+            i = stmt.bind(&main_worktree_paths_order, i)?;
+            stmt.bind(&remote_connection, i)?;
             stmt.exec()
         })
         .await
@@ -1005,6 +1098,8 @@ impl Column for ThreadMetadata {
             Column::column(statement, next)?;
         let (main_worktree_paths_order_str, next): (Option<String>, i32) =
             Column::column(statement, next)?;
+        let (remote_connection_json, next): (Option<String>, i32) =
+            Column::column(statement, next)?;
 
         let agent_id = agent_id
             .map(|id| AgentId::new(id))
@@ -1035,6 +1130,12 @@ impl Column for ThreadMetadata {
             })
             .unwrap_or_default();
 
+        let remote_connection = remote_connection_json
+            .as_deref()
+            .map(serde_json::from_str::<RemoteConnectionOptions>)
+            .transpose()
+            .context("deserialize thread metadata remote connection")?;
+
         Ok((
             ThreadMetadata {
                 session_id: acp::SessionId::new(id),
@@ -1044,6 +1145,7 @@ impl Column for ThreadMetadata {
                 created_at,
                 folder_paths,
                 main_worktree_paths,
+                remote_connection,
                 archived,
             },
             next,
@@ -1087,6 +1189,7 @@ mod tests {
     use gpui::TestAppContext;
     use project::FakeFs;
     use project::Project;
+    use remote::WslConnectionOptions;
     use std::path::Path;
     use std::rc::Rc;
 
@@ -1126,19 +1229,37 @@ mod tests {
             created_at: Some(updated_at),
             folder_paths,
             main_worktree_paths: PathList::default(),
+            remote_connection: None,
         }
     }
 
     fn init_test(cx: &mut TestAppContext) {
+        let fs = FakeFs::new(cx.executor());
         cx.update(|cx| {
             let settings_store = settings::SettingsStore::test(cx);
             cx.set_global(settings_store);
+            <dyn Fs>::set_global(fs, cx);
             ThreadMetadataStore::init_global(cx);
             ThreadStore::init_global(cx);
         });
         cx.run_until_parked();
     }
 
+    fn clear_thread_metadata_remote_connection_backfill(cx: &mut TestAppContext) {
+        let kvp = cx.update(|cx| KeyValueStore::global(cx));
+        smol::block_on(kvp.delete_kvp("thread-metadata-remote-connection-backfill".to_string()))
+            .unwrap();
+    }
+
+    fn run_thread_metadata_migrations(cx: &mut TestAppContext) {
+        clear_thread_metadata_remote_connection_backfill(cx);
+        cx.update(|cx| {
+            let migration_task = migrate_thread_metadata(cx);
+            migrate_thread_remote_connections(cx, migration_task);
+        });
+        cx.run_until_parked();
+    }
+
     #[gpui::test]
     async fn test_store_initializes_cache_from_database(cx: &mut TestAppContext) {
         let first_paths = PathList::new(&[Path::new("/project-a")]);
@@ -1340,6 +1461,7 @@ mod tests {
             created_at: Some(now - chrono::Duration::seconds(10)),
             folder_paths: project_a_paths.clone(),
             main_worktree_paths: PathList::default(),
+            remote_connection: None,
             archived: false,
         };
 
@@ -1397,8 +1519,7 @@ mod tests {
             cx.run_until_parked();
         }
 
-        cx.update(|cx| migrate_thread_metadata(cx));
-        cx.run_until_parked();
+        run_thread_metadata_migrations(cx);
 
         let list = cx.update(|cx| {
             let store = ThreadMetadataStore::global(cx);
@@ -1450,6 +1571,7 @@ mod tests {
             created_at: Some(existing_updated_at),
             folder_paths: project_paths.clone(),
             main_worktree_paths: PathList::default(),
+            remote_connection: None,
             archived: false,
         };
 
@@ -1478,8 +1600,7 @@ mod tests {
         save_task.await.unwrap();
         cx.run_until_parked();
 
-        cx.update(|cx| migrate_thread_metadata(cx));
-        cx.run_until_parked();
+        run_thread_metadata_migrations(cx);
 
         let list = cx.update(|cx| {
             let store = ThreadMetadataStore::global(cx);
@@ -1490,6 +1611,82 @@ mod tests {
         assert_eq!(list[0].session_id.0.as_ref(), "existing-session");
     }
 
+    #[gpui::test]
+    async fn test_migrate_thread_remote_connections_backfills_from_workspace_db(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+
+        let folder_paths = PathList::new(&[Path::new("/remote-project")]);
+        let updated_at = Utc::now();
+        let metadata = make_metadata(
+            "remote-session",
+            "Remote Thread",
+            updated_at,
+            folder_paths.clone(),
+        );
+
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            store.update(cx, |store, cx| {
+                store.save(metadata, cx);
+            });
+        });
+        cx.run_until_parked();
+
+        let workspace_db = cx.update(|cx| WorkspaceDb::global(cx));
+        let workspace_id = workspace_db.next_id().await.unwrap();
+        let serialized_paths = folder_paths.serialize();
+        let remote_connection_id = 1_i64;
+        workspace_db
+            .write(move |conn| {
+                let mut stmt = Statement::prepare(
+                    conn,
+                    "INSERT INTO remote_connections(id, kind, user, distro) VALUES (?1, ?2, ?3, ?4)",
+                )?;
+                let mut next_index = stmt.bind(&remote_connection_id, 1)?;
+                next_index = stmt.bind(&"wsl", next_index)?;
+                next_index = stmt.bind(&Some("anth".to_string()), next_index)?;
+                stmt.bind(&Some("Ubuntu".to_string()), next_index)?;
+                stmt.exec()?;
+
+                let mut stmt = Statement::prepare(
+                    conn,
+                    "UPDATE workspaces SET paths = ?2, paths_order = ?3, remote_connection_id = ?4, timestamp = CURRENT_TIMESTAMP WHERE workspace_id = ?1",
+                )?;
+                let mut next_index = stmt.bind(&workspace_id, 1)?;
+                next_index = stmt.bind(&serialized_paths.paths, next_index)?;
+                next_index = stmt.bind(&serialized_paths.order, next_index)?;
+                stmt.bind(&Some(remote_connection_id as i32), next_index)?;
+                stmt.exec()
+            })
+            .await
+            .unwrap();
+
+        clear_thread_metadata_remote_connection_backfill(cx);
+        cx.update(|cx| {
+            migrate_thread_remote_connections(cx, Task::ready(Ok(())));
+        });
+        cx.run_until_parked();
+
+        let metadata = cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            store
+                .read(cx)
+                .entry(&acp::SessionId::new("remote-session"))
+                .cloned()
+                .expect("expected migrated metadata row")
+        });
+
+        assert_eq!(
+            metadata.remote_connection,
+            Some(RemoteConnectionOptions::Wsl(WslConnectionOptions {
+                distro_name: "Ubuntu".to_string(),
+                user: Some("anth".to_string()),
+            }))
+        );
+    }
+
     #[gpui::test]
     async fn test_migrate_thread_metadata_archives_beyond_five_most_recent_per_project(
         cx: &mut TestAppContext,
@@ -1538,8 +1735,7 @@ mod tests {
             cx.run_until_parked();
         }
 
-        cx.update(|cx| migrate_thread_metadata(cx));
-        cx.run_until_parked();
+        run_thread_metadata_migrations(cx);
 
         let list = cx.update(|cx| {
             let store = ThreadMetadataStore::global(cx);

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -245,6 +245,7 @@ fn save_thread_metadata(
             folder_paths,
             main_worktree_paths,
             archived: false,
+            remote_connection: None,
         };
         ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
     });
@@ -813,6 +814,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                     updated_at: Utc::now(),
                     created_at: Some(Utc::now()),
                     archived: false,
+                    remote_connection: None,
                 },
                 icon: IconName::ZedAgent,
                 icon_from_external_svg: None,
@@ -836,6 +838,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                     updated_at: Utc::now(),
                     created_at: Some(Utc::now()),
                     archived: false,
+                    remote_connection: None,
                 },
                 icon: IconName::ZedAgent,
                 icon_from_external_svg: None,
@@ -853,6 +856,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                 metadata: ThreadMetadata {
                     session_id: acp::SessionId::new(Arc::from("t-3")),
                     agent_id: AgentId::new("zed-agent"),
+                    remote_connection: None,
                     folder_paths: PathList::default(),
                     main_worktree_paths: PathList::default(),
                     title: "Error thread".into(),
@@ -872,10 +876,12 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                 diff_stats: DiffStats::default(),
             }),
             // Thread with WaitingForConfirmation status, not active
+            // remote_connection: None,
             ListEntry::Thread(ThreadEntry {
                 metadata: ThreadMetadata {
                     session_id: acp::SessionId::new(Arc::from("t-4")),
                     agent_id: AgentId::new("zed-agent"),
+                    remote_connection: None,
                     folder_paths: PathList::default(),
                     main_worktree_paths: PathList::default(),
                     title: "Waiting thread".into(),
@@ -895,10 +901,12 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                 diff_stats: DiffStats::default(),
             }),
             // Background thread that completed (should show notification)
+            // remote_connection: None,
             ListEntry::Thread(ThreadEntry {
                 metadata: ThreadMetadata {
                     session_id: acp::SessionId::new(Arc::from("t-5")),
                     agent_id: AgentId::new("zed-agent"),
+                    remote_connection: None,
                     folder_paths: PathList::default(),
                     main_worktree_paths: PathList::default(),
                     title: "Notified thread".into(),
@@ -2197,6 +2205,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
                 folder_paths: PathList::default(),
                 main_worktree_paths: PathList::default(),
                 archived: false,
+                remote_connection: None,
             },
             &workspace_a,
             false,
@@ -2253,6 +2262,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
                 folder_paths: PathList::default(),
                 main_worktree_paths: PathList::default(),
                 archived: false,
+                remote_connection: None,
             },
             &workspace_b,
             false,
@@ -3692,6 +3702,7 @@ async fn test_activate_archived_thread_with_saved_paths_activates_matching_works
                 folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
                 main_worktree_paths: PathList::default(),
                 archived: false,
+                remote_connection: None,
             },
             window,
             cx,
@@ -3757,6 +3768,7 @@ async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
                 folder_paths: PathList::new(&[std::path::PathBuf::from("/project-b")]),
                 main_worktree_paths: PathList::default(),
                 archived: false,
+                remote_connection: None,
             },
             window,
             cx,
@@ -3820,6 +3832,7 @@ async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
                 folder_paths: PathList::default(),
                 main_worktree_paths: PathList::default(),
                 archived: false,
+                remote_connection: None,
             },
             window,
             cx,
@@ -3875,6 +3888,7 @@ async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut
                 folder_paths: path_list_b,
                 main_worktree_paths: PathList::default(),
                 archived: false,
+                remote_connection: None,
             },
             window,
             cx,
@@ -3929,6 +3943,7 @@ async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &m
                 folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
                 main_worktree_paths: PathList::default(),
                 archived: false,
+                remote_connection: None,
             },
             window,
             cx,
@@ -4006,6 +4021,7 @@ async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_t
                 folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
                 main_worktree_paths: PathList::default(),
                 archived: false,
+                remote_connection: None,
             },
             window,
             cx,
@@ -4086,6 +4102,7 @@ async fn test_activate_archived_thread_prefers_current_window_for_matching_paths
                 folder_paths: PathList::new(&[PathBuf::from("/project-a")]),
                 main_worktree_paths: PathList::default(),
                 archived: false,
+                remote_connection: None,
             },
             window,
             cx,
@@ -6006,6 +6023,7 @@ async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &m
             folder_paths: PathList::new(&[PathBuf::from("/project")]),
             main_worktree_paths: PathList::default(),
             archived: false,
+            remote_connection: None,
         };
         ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
     });
@@ -6361,6 +6379,7 @@ mod property_test {
             folder_paths: path_list,
             main_worktree_paths,
             archived: false,
+            remote_connection: None,
         };
         cx.update(|_, cx| {
             ThreadMetadataStore::global(cx)
@@ -7094,6 +7113,7 @@ async fn test_remote_project_integration_does_not_briefly_render_as_separate_pro
             folder_paths: PathList::new(&[PathBuf::from("/project-wt-1")]),
             main_worktree_paths,
             archived: false,
+            remote_connection: None,
         };
         ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
     });